Merge upstream/main into ui-text-hinting

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Sonnet 5.5 committed 2026-10-25 10:14:50 +01:00
commit 402ee4893c
109 files changed
+8649 -561

No files matched your search

-2
View File
@@ -21,8 +21,6 @@ jobs:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
-2
View File
@@ -27,5 +27,3 @@ jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
Generated
+1505 -53
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -54,7 +54,7 @@ harfrust = "0.13"
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -110,4 +110,4 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
+44 -1
View File
@@ -1,8 +1,10 @@
# WordCraft roadmap
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (progress log: Spanish proofing, #3360, #3553; previously trivial: dark-theme interface text on Linux no longer looks semi-bold; 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: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wrapsLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (dark-theme interface text on Linux no longer looks semi-bold; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes 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 wholLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (dark-theme interface text on Linux no longer looks semi-bold; previously trivial: English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -136,16 +138,32 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087).
- 2026-10-23: cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison.
- 2026-10-23: cu20we: paragraph-relative box floats start above effective space before; header, footer, cell and text-box layout regressions pass. Framed paragraph positioning is unchanged; no new native Word comparison.
- 2026-10-23: AutoFormat As You Type: `*Bold* and _italic_ as real formatting`. With the option on, `*word*` typed and finished becomes bold and `_word_` italic, without the marks. It is off by default (`tools.autocorrect {"boldItalic"}`).
- 2026-10-24: Parity catalog: Read Mode's View menu has its own rows. Column Width (`view.readColumnWidth`) and Page Color (`view.readPageColor`) count as live; Layout (`view.readLayout`) is listed as missing. Catalog 504/520 (96.9%).
- 2026-10-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.
- 2026-10-24: A line touching a floating table at its bottom edge keeps clear of the table (#3977), in both row-span selection and word skipping. Shapes keep their strict overlap rule. Original Source Sans 3 boundary and narrow-strip controls cover modes 11/12/14/15; the expected Word distinction is cyril265's Word for Mac 16.114.1 observation.
- 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document.
- 2026-10-24: AutoFormat As You Type has "Built-in Heading styles" (off by default): pressing Enter twice after a line of five or fewer words that ends in a letter or digit makes it Heading 1, or Heading 2 or 3 when the line starts with one or two tabs.
- 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison.
- 2026-10-24: AutoFormat: a paragraph numbered through its style (a heading in a heading-linked Multilevel List) keeps `---` and `+---+` as text on Enter, as a paragraph numbered directly does. At the top of the document the border-line AutoFormat removed the characters and switched off the heading's number, and left no border.
- 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982).
- 2026-10-24: Spelling: with Ignore web and file addresses on, a web or e-mail address after a no-break space or an ideographic space is skipped, as it is after a plain space. Its words were marked as misspelled.
- 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663).
- 2026-10-24: AutoFormat As You Type has "Set left- and first-indent with tabs and backspaces" (off by default): Tab at the start of a paragraph with text indents its first line, Tab at the start of a later line indents the whole paragraph, and Backspace at the start takes the indents back.
- 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637).
- 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools). Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged
- 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed.
- 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891).
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs.
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
- 2026-10-24: Track Changes: the border-line (`---` and the others) and `+---+` table AutoFormats mark the typed characters deleted instead of removing them, so Reject All brings them back. Characters the same author typed with tracking on are removed, as a tracked Backspace removes them. A border at the top of a story is a tracked formatting change too, as the border on the paragraph above already was. The table is inserted untracked, as Insert › Table inserts one. A line that holds a tracked deletion no longer triggers either AutoFormat.
Four geometry-dependent layout controls request Source Sans 3 explicitly. Installed substitute fonts no longer choose their line breaks. Assertions and renderer font fallback policy are unchanged.
@@ -170,7 +188,12 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. |
| 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only |
| 2026-10-25 | #4085 mark review: whole-cell and ordinary highlight targets include their selected paragraph marks; empty, inherited and partial-column controls added. |
| 2026-10-25 | #4085 review: highlight toggling checks the full target with constant resolved-run storage; a different colour after 10,000 runs prevents clearing. |
| 2026-10-24 | #4085: Ctrl+Alt+H toggles the last chosen highlight colour; explicit colour setters remain idempotent. Mixed, inherited and pending formatting and Undo/Redo covered by command/key regressions. |
| 2026-10-23 | Comment times show in the user's own time zone, as in Word: the Comments pane converts them (it showed the file's UTC time) and the balloons show them under each author (they showed none). The file's UTC time (Word's `w16cex:dateUtc`) wins over `w:date`, a time naming its zone is converted, and one without a zone shows as written (#2322, #2337) |
| 2026-10-22 | Compare results opened as new or revised untitled documents start without the previous document's version snapshots. A comparison or mailing result rejected by protection keeps the snapshots and save target with the original document. |
| 2026-10-20 | The Document Inspector's removals are refused while the document tracks changes: they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back |
| 2026-10-20 | A version snapshot belongs to the document it was taken from: the snapshots went with the document, so a restore can only ever bring back a version of the document that is open |
@@ -296,6 +319,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-16 | Create Source: the automatic Tag is three letters of the first author and the last two digits of the year (`Smi20`), with 1, 2… added when taken, as Word for Mac 16.113.4 does (user-checked). Editing Author or Year keeps the Tag, as in Word, and the citations in the text show the new author and year (#2094; the reporter expected the Tag to follow). |
| 2026-10-18 | Shape fills keep and draw gradients and transparency (#1500): Word 365 cover pages show their theme gradient panel and translucent wave; DrawingML and VML shapes, text boxes, group members (`a:grpFill`) and canvases are read, drawn (screen, PNG, PDF) and saved with their gradient stops, angles, focus and transparency; theme fill styles (`a:fillRef`) resolve to the fill they name. `.json` documents keep opaque fills as `[r,g,b]`. Edited as in Word: Shape Fill › Gradient (Light and Dark Variations) and the Format Shape pane (solid colour and transparency; gradient presets, type, direction, angle and stops) |
| 2026-10-18 | Split Cells and Convert Text to Table dialogs (#2592), matched to Word: Split Cells starts from the selection (twice its columns, its rows; Merge cells before split offered for several cells or one cell merged across columns); `table.split` gains `rows` and `mergeFirst`, a merged cell splits into 1 to its height rows (its height cuts it into rows, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages) and column splits make equal parts without moving other rows' grid lines. Convert Text to Table guesses columns live, shows the row count, and offers Initial column width (Auto or up to 22 in, kept as each column's preferred width), AutoFit to contents/window and Paragraphs/Commas/Tabs/Other separators; `table.fromText` gains `paragraph`/`other` + `char`, `columns`, `autofit` and `width`. |
| 2026-10-24 | Table Layout › Convert to Text opens its dialog: Separate text with Paragraph marks, Tabs (the default), Commas or Other. With rows selected only those rows become text, and the rows above and below stay tables; the caret alone converts the whole table. The text keeps its formatting and objects, and a table nested in a cell stays a table; `table.toText` gains `other` + `char`. |
| 2026-10-16 | Font list entries of fonts with a very tall ascent (Amiri, Amiri Quran) sit in their row like the others instead of low or cut in half (#2570) |
| 2026-10-16 | Read Mode draws documents cleanly (#1772): the arrows, Page Up/Down and Escape work as soon as it opens (the canvas kept a stable widget id, so it no longer lost the keyboard), only whole columns show, centred, at any column width, text size or window size, and the columns end above the page-turn bar instead of under it. |
| 2026-10-16 | Comment and revision balloons are readable in the dark theme (#2045): they were drawn on a fixed white card under light text; the card, markup area and author colours are now theme tokens with a contrast test. |
@@ -437,6 +461,9 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
| 2026-10-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders |
| 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout |
| 2026-10-24 | Document information fields: SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM and NUMCHARS update with the other fields and are listed in the Field dialog; a quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces that document property (at most 255 characters), and NUMWORDS, NUMCHARS and REVNUM take a `\*` number format |
| 2026-10-24 | Field capitalization switches: `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the document information, DOCPROPERTY, FILENAME, date, REF, PAGEREF, SEQ, STYLEREF and table formula results that Update Field computes and to the page-number fields, and `\* CardText` and `\* OrdText` results are lowercase unless one of them follows (`\* CardText \* Caps` shows "Seven Hundred Ninety"); a table formula takes a `\*` number format (`=SUM(A1:B2) \* CardText`), and other `\*` switches such as `\* MERGEFORMAT` no longer make it a syntax error |
| 2026-10-24 | Field `\* DollarText` and `\* Hex` switches: `{ = 9.20 + 5.35 \* DollarText \* Upper }` shows "FOURTEEN AND 55/100" and `\* Hex` writes uppercase hexadecimal digits (458 shows "1CA"), in table formulas and in the page-number, SEQ, PAGEREF and count fields; `{ PAGE \* ArabicDash }` shows "- 31 -"; cardinal, ordinal and dollar text past 999,999 keep their digits |
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
| 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing |
@@ -599,14 +626,30 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
## Revision history
> 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) |
| 2026-10-23 | minor | A command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it. |
| 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged |
| 2026-10-23 | trivial | cu20we: box float paragraph origins corrected, synthetic regressions added |
| 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged |
| 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: AutoCorrect corrects a word typed after a no-break space or an ideographic space |
| 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). |
| 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). |
| 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). |
| 2026-10-24 | trivial | Progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting |
| 2026-10-24 | trivial | Progress log: the parity catalog counts Read Mode's View menu (Column Width, Page Color, Layout) |
| 2026-10-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) |
| 2026-10-23 | trivial | Progress log: a relationship the markup names without the prefix is read as it wrote (#3411 follow-up) |
| 2026-10-21 | trivial | Progress log: a document with many relationships opens in the time its part takes (#3411) |
| 2026-10-23 | trivial | Progress log: an empty paragraph with a hidden mark draws no list number (#3982) |
| 2026-10-22 | minor | Progress log: Spanish proofing (#3360, #3553) |
| 2026-10-23 | trivial | Progress log: Update Table keeps a two-column TOC from a localized Word in its columns (#3889) |
| 2026-10-22 | minor | Compare's new and revised untitled results clear the previous document's version snapshots; a rejected protected comparison or mailing result preserves the snapshots and file identity. |
| 2026-10-22 | minor | The CLI author flag and the Options user name field cannot change the session name after protection starts (#3279 follow-up). |
| 2026-10-21 | trivial | Progress log: a word with no break opportunity in it no longer makes the layout quadratic (#3412) |
+31 -4
View File
@@ -379,6 +379,14 @@ fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
raw.get(at + 1).map(|a| PathBuf::from(a.as_ref()))
}
/// Whether `addr` (`HOST:PORT`, as `--connect` gives it) is at least shaped like an address: a
/// host of some kind and a numeric port. Malformed addresses fail at startup — a lazy remote
/// would otherwise retry one on every call and never say why nothing connects.
fn valid_connect_addr(addr: &str) -> bool {
let Some((host, port)) = addr.rsplit_once(':') else { return false };
!host.is_empty() && port.parse::<u16>().is_ok()
}
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let script_path = script_operand(args);
let (args, file) = text_args(args)?;
@@ -582,11 +590,27 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
}
Some(addr) => {
let remote = wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?;
if let Some(w) = remote.key_warning() {
eprintln!("wordcraft-cli: {w}");
// A malformed address must fail at startup, not on every tool call the lazy
// remote would retry it with.
if !valid_connect_addr(&addr) {
return Err(format!("mcp: --connect needs HOST:PORT with a numeric port, got `{addr}`").into());
}
match wordcraft_mcp::Remote::connect(&addr) {
Ok(remote) => {
eprintln!("wordcraft-cli mcp: connected to WordCraft at {addr}");
if let Some(w) = remote.key_warning() {
eprintln!("wordcraft-cli: {w}");
}
Box::new(remote)
}
// The app may start after the client does: serve anyway and connect on the
// first call, or the client marks the server failed for its whole session
// (#4078).
Err(e) => {
eprintln!("wordcraft-cli mcp: WordCraft is not reachable at {addr} yet ({e}); will retry on each call");
Box::new(wordcraft_mcp::Remote::lazy(&addr))
}
}
Box::new(remote)
}
None => {
let mut headless = wordcraft_mcp::Headless::default();
@@ -807,6 +831,9 @@ mod tests {
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
// The app's user name is the person's own; a bridge flag must not rewrite it.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
// A malformed address fails at startup, not on every retried call.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
// The flag goes through the same protection check as the command.
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
+1 -1
View File
@@ -29,4 +29,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"] }
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"] }
+169
View File
@@ -0,0 +1,169 @@
//! 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`:
//!
//! - `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:
//! - 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.
//! - Save As, and File › Export to another document format (which runs Save As): `true` for a
//! format that keeps everything (.docx and the other Word packages, .odt, .rtf). The document
//! 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.
//!
//! 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.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// 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,
}
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 })
}
/// Hand saved and exported files to the parent 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.
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));
}
}));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
pub fn set_author(app: &mut WordApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.author = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
}
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:?}")))
}
/// Hear `wordcraft:open` from the parent 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;
}
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
}
}
+5
View File
@@ -8,6 +8,9 @@
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
//! 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.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
@@ -15,6 +18,8 @@ mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod web;
+43 -2
View File
@@ -25,17 +25,35 @@ pub fn restore(app: &mut WordApp, stored: Option<&str>) {
#[derive(Default)]
pub struct PrefsSync {
last: Option<String>,
/// A host page's name for the user (`?author=`) and the user's own, which is stored in its
/// place while the user keeps the host's.
host_author: Option<(String, String)>,
}
impl PrefsSync {
/// Starts from what is already stored, so restoring doesn't write it straight back.
pub fn new(stored: Option<String>) -> Self {
Self { last: stored }
Self { last: stored, host_author: None }
}
/// The session signs with `host`, a host page's name for the user, but `own` stays the
/// stored name. A name the user then chooses in File › Options is stored as usual.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub fn keep_own_author(&mut self, host: &str, own: &str) {
if host != own {
self.host_author = Some((host.to_string(), own.to_string()));
}
}
/// The JSON to store when the app's preferences differ from what was last written.
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
let json = match serde_json::to_string(&app.prefs()) {
let mut prefs = app.prefs();
if let Some((host, own)) = &self.host_author
&& prefs.author == *host
{
prefs.author = own.clone();
}
let json = match serde_json::to_string(&prefs) {
Ok(j) => j,
Err(e) => {
log::warn!("preferences: cannot serialize: {e}");
@@ -60,6 +78,29 @@ mod tests {
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
}
/// A host page's name for the user signs the visit but isn't stored; a name the user then
/// chooses is.
#[test]
fn host_author_is_not_stored() {
let mut a = app();
a.session.author = "Grace Hopper".into();
let mut sync = PrefsSync::new(None);
let stored = sync.changed(&a).unwrap();
a.session.author = "rob".into();
sync.keep_own_author("rob", "Grace Hopper");
a.run("ui.theme", json!({"value": "dark"})).unwrap();
let json = sync.changed(&a).expect("the theme changed");
assert_ne!(json, stored);
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Grace Hopper");
a.session.author = "Rob Hones".into();
let json = sync.changed(&a).expect("the user chose a name");
let mut next = app();
restore(&mut next, Some(&json));
assert_eq!(next.session.author, "Rob Hones");
}
/// #2132: dark mode chosen on one visit is there after a reload.
#[test]
fn dark_mode_survives_reload() {
+35 -4
View File
@@ -27,9 +27,15 @@ 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 };
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
if bridge.is_none()
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
{
log::error!("no unsaved-changes guard: {e}");
}
let mut options = eframe::WebOptions::default();
@@ -56,12 +62,17 @@ pub fn start() {
// The browser's locale picks the new document's paper, the units and the
// decimal separator (#838).
let region = wordcraft_ui_egui::i18n::system_region();
let sample = query().contains("sample");
// In host mode the parent sends the document to edit; no sample meanwhile.
let sample = app_bridge.is_none() && query().contains("sample");
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let mut session = Session::new(doc);
session.system_region = region;
let messages = Rc::new(RefCell::new(Vec::new()));
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = WordApp::new(session, services);
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
@@ -74,15 +85,31 @@ pub fn start() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
let prefs = crate::prefs::PrefsSync::new(stored);
let mut prefs = crate::prefs::PrefsSync::new(stored);
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
// The host's name for the user signs this visit's work, but isn't stored as the
// browser's own preference, which a visit outside the host would restore.
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
let own = app.session.author.clone();
crate::host::Bridge::set_author(&mut app, &author);
prefs.keep_own_author(&app.session.author, &own);
}
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -96,6 +123,10 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
+1 -1
View File
@@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
+41 -3
View File
@@ -95,7 +95,8 @@ pub fn formats() -> Vec<String> {
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything.
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
/// from the header before anything is decoded (#3623).
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
@@ -109,6 +110,19 @@ pub fn picture() -> Option<ClipboardPicture> {
{
return Some(ClipboardPicture::Files(files));
}
decoded_picture(&mut clipboard)
}
/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats
/// with no size limits (#3623).
#[cfg(target_os = "windows")]
fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
platform::picture()
}
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
#[cfg(not(target_os = "windows"))]
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
@@ -187,6 +201,7 @@ mod platform {
#[cfg(target_os = "windows")]
mod platform {
use clipboard_win::{Clipboard, EnumFormats, raw};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
pub fn formats() -> Vec<String> {
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
@@ -205,8 +220,31 @@ mod platform {
Some(out)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
/// it holds neither.
pub fn picture() -> Option<ClipboardPicture> {
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
let Ok(_open) = Clipboard::new_attempts(10) else {
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
};
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
}
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
}
/// A clipboard format's bytes, refused past the largest picture a paste takes.
fn read(format: u32) -> Result<Vec<u8>, String> {
match raw::size(format) {
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
None => return Err("the clipboard picture is empty".into()),
}
let mut out = Vec::new();
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
Ok(out)
}
pub fn can_set() -> bool {
+1 -1
View File
@@ -13,7 +13,7 @@ pub fn discover() -> Discover {
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
+3
View File
@@ -176,5 +176,8 @@ display_name = "Mulai"
[people.Bortlesboat]
real_name = "Andrew Barnes"
[people.matstrange]
sync_github_name = true
[people.pakiat]
display_name = "OtorO"
+50
View File
@@ -13,6 +13,41 @@ use crate::graphic::PathSeg;
use crate::para::{InlineObject, ShapeKind};
use crate::wordart::TextWarp;
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineCap {
#[default]
Flat,
Square,
Round,
}
impl LineCap {
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
pub fn from_ooxml(value: &str) -> Self {
match value {
"sq" => Self::Square,
"rnd" => Self::Round,
_ => Self::Flat,
}
}
/// Return the canonical DrawingML spelling used when saving the outline.
pub fn ooxml(self) -> &'static str {
match self {
Self::Flat => "flat",
Self::Square => "sq",
Self::Round => "rnd",
}
}
/// Whether the cap can be omitted from older-compatible serialized shape properties.
pub fn is_flat(&self) -> bool {
*self == Self::Flat
}
}
/// The rest of a shape's properties. Empty (the default) for most shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -41,6 +76,9 @@ pub struct ShapeExtra {
/// The outline dash pattern; absent in older saved documents means solid.
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
pub dash: crate::line_dash::LineDash,
/// The outline ends; older saved documents and absent DrawingML caps are flat.
#[serde(skip_serializing_if = "LineCap::is_flat")]
pub cap: LineCap,
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
@@ -272,6 +310,18 @@ mod tests {
use super::*;
use crate::para::{Float, GroupChild};
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
#[test]
fn older_shape_json_defaults_to_flat_caps() {
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
assert_eq!(extra.cap, LineCap::Flat);
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
for cap in [LineCap::Square, LineCap::Round] {
let extra = ShapeExtra { cap, ..Default::default() };
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
}
}
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
InlineObject::Shape {
kind,
+155 -1
View File
@@ -179,6 +179,95 @@ pub fn para_block(p: Paragraph) -> Arc<Block> {
Arc::new(Block::Para(p))
}
/// The same blocks: the same content. The blocks are compared by address first — the
/// document is copy-on-write, so a block that changed was copied and has another address —
/// and a block that was copied but holds the same content is compared by value.
fn same_blocks(a: &Blocks, b: &Blocks) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y))
}
/// The same block by value. A table's cells compare their blocks by address first too: an edit in
/// one cell copies the whole table, which mustn't mean comparing every cell by value.
fn same_block(a: &Block, b: &Block) -> bool {
match (a, b) {
(Block::Table(x), Block::Table(y)) => same_table(x, y),
_ => a == b,
}
}
/// The same table. Every field is listed, so a new one does not build until someone decides
/// whether a change to it counts.
fn same_table(a: &Table, b: &Table) -> bool {
let Table { props, grid, rows, controls } = a;
let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b;
props == other_props
&& grid == other_grid
&& controls == other_controls
&& rows.len() == other_rows.len()
&& rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y))
}
fn same_row(a: &Row, b: &Row) -> bool {
let Row { props, cells, controls } = a;
let Row { props: other_props, cells: other_cells, controls: other_controls } = b;
props == other_props
&& controls == other_controls
&& cells.len() == other_cells.len()
&& cells.iter().zip(other_cells).all(|(x, y)| {
let Cell { props, blocks, controls } = x;
let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y;
props == other_props && controls == other_controls && same_blocks(blocks, other_blocks)
})
}
/// The same building blocks, their content compared by address first.
fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool {
a.len() == b.len()
&& a.iter().zip(b).all(|(x, y)| {
let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x;
let BuildingBlock {
name: other_name,
gallery: other_gallery,
category: other_category,
description: other_description,
guid: other_guid,
style: other_style,
types: other_types,
behaviors: other_behaviors,
blocks: other_blocks,
} = y;
name == other_name
&& gallery == other_gallery
&& category == other_category
&& description == other_description
&& guid == other_guid
&& style == other_style
&& types == other_types
&& behaviors == other_behaviors
&& same_blocks(blocks, other_blocks)
})
}
/// The same parts: the same keys, and each part the same.
fn same_parts(a: &BTreeMap<u32, Part>, b: &BTreeMap<u32, Part>) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2))
}
/// The same part: the same kind, text body and blocks. Every field is listed, so a new one
/// does not build until someone decides whether a change to it counts.
fn same_part(a: &Part, b: &Part) -> bool {
let Part { kind, blocks, body } = a;
let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b;
kind == other_kind && body == other_body && same_blocks(blocks, other_blocks)
}
/// The same bytes: the same content. The bytes are compared by address first — a changed
/// picture was copied, so it has another address — and a picture that was copied but holds
/// the same bytes is compared by value.
fn same_bytes(a: &BTreeMap<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
}
/// Which story a position is in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -356,7 +445,8 @@ pub struct Comment {
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
#[serde(skip_serializing_if = "Option::is_none")]
pub durable_id: Option<String>,
/// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there.
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, where `date` is local time),
/// or the time WordCraft stamped the comment with.
#[serde(skip_serializing_if = "Option::is_none")]
pub date_utc: Option<String>,
}
@@ -838,6 +928,70 @@ impl Document {
}
}
/// Whether the document holds the same content as `other`: the same blocks, the same
/// styles, lists, settings, comments and the rest.
///
/// The blocks are compared by address first: the document is copy-on-write, so a block
/// that changed was copied and has another address. Comparing them by value would walk
/// the whole document, and this is asked after every command.
///
/// Every field is listed, so a new one does not build until someone decides whether a
/// change to it counts (the same rule the session's edit snapshot follows).
pub fn same_content(&self, other: &Document) -> bool {
let Document {
body,
last_section,
parts,
styles,
numbering,
comments,
revisions,
settings,
core,
sources,
custom_props,
footnote_separators,
endnote_separators,
media,
passthrough,
glossary,
} = self;
let Document {
body: other_body,
last_section: other_section,
parts: other_parts,
styles: other_styles,
numbering: other_numbering,
comments: other_comments,
revisions: other_revisions,
settings: other_settings,
core: other_core,
sources: other_sources,
custom_props: other_props,
footnote_separators: other_footnotes,
endnote_separators: other_endnotes,
media: other_media,
passthrough: other_passthrough,
glossary: other_glossary,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
&& last_section == other_section
&& styles == other_styles
&& numbering == other_numbering
&& comments == other_comments
&& revisions == other_revisions
&& settings == other_settings
&& core == other_core
&& sources == other_sources
&& custom_props == other_props
&& footnote_separators == other_footnotes
&& endnote_separators == other_endnotes
&& same_bytes(media, other_media)
&& same_bytes(passthrough, other_passthrough)
&& same_glossary(glossary, other_glossary)
}
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
match s {
StoryRef::Body => Some(&self.body),
+48
View File
@@ -165,6 +165,54 @@ impl Table {
self.rows.remove(r);
}
/// Split the table before row `at`: the rows from `at` on move to the returned table, which
/// has this table's properties and grid. A vertical merge that the split cuts ends in this
/// table's last row and starts again in the new table's first row. A row content control
/// around rows on both sides closes after this table's last row and opens again (without its
/// `w:id`) before the new table's first row. Content controls that close after this table
/// close after the new one.
pub fn split_off(&mut self, at: usize) -> Table {
let at = at.min(self.rows.len());
let mut open = Vec::new();
for row in self.rows.iter().take(at) {
open.extend(row.controls.open.iter().cloned());
let close = usize::try_from(row.controls.close).unwrap_or(usize::MAX).min(open.len());
open.truncate(open.len() - close);
}
let mut tail = Table {
props: self.props.clone(),
grid: self.grid.clone(),
rows: self.rows.split_off(at),
controls: ControlWrap { open: Vec::new(), close: std::mem::take(&mut self.controls.close) },
};
if let Some(last) = self.rows.last_mut() {
last.controls.close = last.controls.close.saturating_add(u32::try_from(open.len()).unwrap_or(u32::MAX));
for c in &mut last.cells {
if c.props.vmerge == VMerge::Restart {
c.props.vmerge = VMerge::None;
}
}
}
for c in &mut open {
c.id = None;
}
if let Some(first) = tail.rows.first_mut() {
let own = std::mem::replace(&mut first.controls.open, open);
first.controls.open.extend(own);
}
let starts: Vec<(usize, usize)> = (0..tail.rows.first().map_or(0, |row| row.cells.len()))
.filter(|&c| tail.rows.first().and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Continue))
.map(|c| (c, tail.grid_col(0, c)))
.collect();
for (c, g) in starts {
let more = tail.cell_at_grid(1, g).and_then(|i| tail.rows.get(1)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue);
if let Some(x) = tail.rows.first_mut().and_then(|r| r.cells.get_mut(c)) {
x.props.vmerge = if more { VMerge::Restart } else { VMerge::None };
}
}
tail
}
/// Insert a grid column at grid index `g` (cells spanning it widen instead).
pub fn insert_col(&mut self, g: usize) {
if self.grid.len() >= MAX_COLS {
+66
View File
@@ -141,6 +141,72 @@ fn clone_is_shallow() {
assert_eq!(d.plain_text(StoryRef::Body), "a\nb");
}
/// [`Document::same_content`] is what tells the engine that a command changed nothing: it
/// must see every change, and it must not call two different documents the same.
#[test]
fn same_content_sees_every_change() {
let d = Document::from_text("Hello\nWorld");
assert!(d.same_content(&d.clone()), "a copy is the same document");
// The text changed.
let mut e = d.clone();
e.insert_text(&Pos::body(0, 0), "x", &CharProps::default()).unwrap();
assert!(!d.same_content(&e), "the text changed");
// The formatting changed.
let mut e = d.clone();
e.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
assert!(!d.same_content(&e), "the formatting changed");
// The styles changed.
let mut e = d.clone();
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
assert!(!d.same_content(&e), "a style changed");
// The lists changed.
let mut e = d.clone();
e.numbering.add_list(ListKind::Numbered);
assert!(!d.same_content(&e), "a list was added");
// The settings changed.
let mut e = d.clone();
e.settings.track_changes = true;
assert!(!d.same_content(&e), "a setting changed");
// The metadata changed.
let mut e = d.clone();
e.core.title = "Draft".into();
assert!(!d.same_content(&e), "the title changed");
// A part changed.
let mut e = d.clone();
e.add_part(PartKind::Header, vec![para_block(Paragraph::new())]);
assert!(!d.same_content(&e), "a part was added");
// A picture changed.
let mut e = d.clone();
e.media.insert("i.png".into(), Arc::new(vec![1, 2, 3]));
assert!(!d.same_content(&e), "a picture was added");
// A revision was recorded.
let mut e = d.clone();
e.revisions.push(Revision::new(RevisionKind::Insert, "Mira", "2026-10-23T00:00:00Z"));
assert!(!d.same_content(&e), "a revision was recorded");
// A comment was added.
let mut e = d.clone();
e.comments.insert(1, Comment { author: "Mira".into(), ..Default::default() });
assert!(!d.same_content(&e), "a comment was added");
// A paragraph that was marked as changed but holds the same content is the same.
let mut e = d.clone();
e.para_mut(StoryRef::Body, &Path::top(0)).unwrap().touch();
assert!(d.same_content(&e), "the paragraph only changed its counter");
// A table copied for an edit: the same until a cell's content changes.
let mut d = Document::from_text("Hello");
d.body.insert(0, Arc::new(Block::Table(Table::new(2, 2, 200.0))));
let mut e = d.clone();
Arc::make_mut(&mut e.body[0]);
assert!(!Arc::ptr_eq(&d.body[0], &e.body[0]));
assert!(d.same_content(&e), "the table was only copied");
let Block::Table(t) = Arc::make_mut(&mut e.body[0]) else { panic!("a table") };
t.rows[1].cells[1].blocks[0] = para_block(Paragraph::with_text("x", CharProps::default()));
assert!(!d.same_content(&e), "a cell's text changed");
// A building block was added.
let mut e = d.clone();
e.glossary.push(BuildingBlock { name: "Sign-off".into(), ..Default::default() });
assert!(!d.same_content(&e), "a building block was added");
}
#[test]
fn json_round_trip() {
let mut d = Document::from_text("Hello\nWorld");
+37 -18
View File
@@ -92,17 +92,18 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
let key_key = derive_key(pe.hash, &hn, &BK_KEY_VALUE, pe.key_bytes);
let key = Zeroizing::new(cbc_decrypt(&key_key, &iv, &pe.key_value)?);
let key = Zeroizing::new(key.get(..info.key_bytes).ok_or_else(|| bad("the encrypted key is too short"))?.to_vec());
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream.
if let Some((enc_key, enc_value)) = &info.integrity {
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
// anyway, so the padded key gives the same HMAC.
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
return Err(bad("the document is damaged (its integrity check failed)"));
}
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream. It is required:
// a file that could drop it would let altered ciphertext, or an altered StreamSize, through
// unauthenticated (`parse_info` refuses a file without it).
let (enc_key, enc_value) = &info.integrity;
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
// anyway, so the padded key gives the same HMAC.
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
return Err(bad("the document is damaged (its integrity check failed)"));
}
// The package itself (§2.3.4.15): StreamSize, then 4096-byte segments, each with its own IV.
let size = package
@@ -610,7 +611,7 @@ struct Info {
block_size: usize,
key_bytes: usize,
/// encryptedHmacKey, encryptedHmacValue.
integrity: Option<(Vec<u8>, Vec<u8>)>,
integrity: (Vec<u8>, Vec<u8>),
password: PasswordEncryptor,
}
@@ -652,10 +653,12 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
}
let key_data = root.els().find(|e| e.local() == "keyData").ok_or_else(|| bad("EncryptionInfo has no keyData"))?;
let kd = params(key_data)?;
let integrity = match root.els().find(|e| e.local() == "dataIntegrity") {
Some(di) => Some((b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?)),
None => None,
};
// The integrity check is mandatory (§2.3.4.14): a file is not trusted to opt out of it.
let di = root
.els()
.find(|e| e.local() == "dataIntegrity")
.ok_or_else(|| bad("EncryptionInfo has no dataIntegrity, so the document cannot be checked for tampering"))?;
let integrity = (b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?);
// The password key encryptor is the `encryptedKey` with a spin count (a certificate key
// encryptor has none).
let encryptors = root.els().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?;
@@ -896,6 +899,22 @@ mod tests {
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
}
#[test]
fn a_file_without_data_integrity_is_refused() {
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
let mut stripped = info[..8].to_vec();
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
comp.flush().unwrap();
let enc = comp.into_inner().into_inner();
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
}
#[test]
fn tampered_package_fails_the_integrity_check() {
let pkg = zip_package();
@@ -955,8 +974,8 @@ mod tests {
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
// Data integrity is optional; without it a good file still opens.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), false));
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
let mut huge = package.clone();
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
+8
View File
@@ -258,6 +258,14 @@ impl Package {
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
}
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
pub fn xml_strict(&self, name: &str) -> Result<Option<El>, DocxError> {
match self.get(name) {
Some(b) => xml::parse_strict(b).map(Some),
None => Ok(None),
}
}
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
match self.get(name) {
Some(b) => xml::parse(b).map(Some),
+1 -1
View File
@@ -395,7 +395,7 @@ mod tests {
// A hyperlink whose relationship id is written without the prefix: the reader resolves
// it (the alias `El::attr` accepts), so the part it names is not carried over twice.
let named = package(
r#"<w:p><w:r><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
r#"<w:p><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
&[("rIdDoc1", "keep.bin")],
&[("word/keep.bin", b"kept")],
);
+22 -2
View File
@@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
let root_rels = pkg.rels("");
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
let Some(root) = pkg.xml(&main)? else {
let Some(root) = pkg.xml_strict(&main)? else {
return Err(DocxError::MissingPart(main));
};
if root.name != "w:document" {
@@ -513,7 +513,9 @@ fn toc_control_to_toc(body: &mut Blocks) {
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
}
}
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
@@ -901,6 +903,24 @@ impl Reader<'_> {
{
self.pc.alias.insert(id.to_string(), "Normal".into());
}
// Word in other languages names the TOC styles' ids in that language (`toc 1` is `Sumrio1`
// in Portuguese): they take the engine's ids, which Update Table and the writer's TOC
// recognise the entries by (#3889). Not when the file or an earlier style has that id.
let mut taken: Vec<String> = root.children("w:style").take(10_000).filter_map(|s| s.attr("w:styleId")).map(str::to_string).collect();
for s in root.children("w:style").take(10_000).filter(|s| s.attr("w:type").is_none_or(|t| t == "paragraph")) {
let (Some(id), Some(name)) = (s.attr("w:styleId"), s.child_val("w:name")) else { continue };
let canonical = match name.trim().to_ascii_lowercase().as_str() {
"toc heading" => "TOCHeading".to_string(),
n => match n.strip_prefix("toc ").and_then(|d| d.parse::<u8>().ok()).filter(|d| (1..=9).contains(d)) {
Some(d) => format!("TOC{d}"),
None => continue,
},
};
if !taken.contains(&canonical) && !self.pc.alias.contains_key(id) {
taken.push(canonical.clone());
self.pc.alias.insert(id.to_string(), canonical);
}
}
let mut sheet = StyleSheet::empty();
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
if let Some(dd) = root.child("w:docDefaults") {
+30 -5
View File
@@ -1066,6 +1066,10 @@ impl Reader<'_> {
// ---- drawings ----
/// Read a `w:drawing` frame and dispatch its payload to the picture, shape or graphic reader.
/// Frame dimensions are converted from EMUs to bounded point values; placement, alternative
/// text and drawing ids are carried into the object. Returns `None` when the frame or payload
/// cannot be read. Ink written by WordCraft keeps its tool's caps rather than an outline cap.
fn read_drawing(&mut self, sc: &mut StoryCtx, d: &El, rels: &Rels) -> Option<InlineObject> {
let (c, anchored) = match d.child("wp:inline") {
Some(c) => (c, false),
@@ -1142,13 +1146,16 @@ impl Reader<'_> {
let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
// A freeform: whether it's ink WordCraft wrote.
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
if let InlineObject::Shape { freeform: Some(f), extra, .. } = &mut obj
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
&& let Some(tool) = super::freeform::ink_tool(name)
{
let f = Arc::make_mut(f);
f.ink = Some(tool);
f.order = super::freeform::ink_order(name);
// Ink's round ends belong to its tool, not the optional shape outline. Keep
// its canonical model unchanged when reading the round cap the writer adds.
extra.cap = Default::default();
}
obj
};
@@ -1461,6 +1468,10 @@ impl Reader<'_> {
}
}
/// Build a shape from `wps:wsp`, using frame dimensions `w` and `h` in points and the caller's
/// placement. Reads geometry, paint, effects and connector ends, and creates a text-box story
/// when present within the nesting limit. Explicit outline caps are preserved; missing or
/// unknown caps use flat ends, without resolving theme-inherited caps.
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject {
let sppr = wsp.child("wps:spPr");
float.set_spin(xfrm_spin(sppr));
@@ -1533,7 +1544,12 @@ impl Reader<'_> {
{
part.body.auto_color = Some(rgb);
}
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
let mut extra = ShapeExtra {
dash: super::shape_style::line_dash(ln),
cap: wordcraft_doc::connector::LineCap::from_ooxml(ln.and_then(|e| e.attr("cap")).unwrap_or("flat")),
outline_fill: paint.outline_fill,
..ShapeExtra::default()
};
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
// rounded rectangles keep the default.
if prst == "roundRect" {
@@ -1728,6 +1744,15 @@ impl Reader<'_> {
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = vml_fill(shape, shapetype);
let endcap = shape
.child("v:stroke")
.and_then(|s| s.attr("endcap"))
.or_else(|| shapetype.and_then(|t| t.child("v:stroke")).and_then(|s| s.attr("endcap")));
let cap = match endcap {
Some("square") => wordcraft_doc::connector::LineCap::Square,
Some("round") => wordcraft_doc::connector::LineCap::Round,
_ => wordcraft_doc::connector::LineCap::Flat,
};
let kind = match shape.local() {
"line" => Some(ShapeKind::Line),
"rect" => Some(ShapeKind::Rectangle),
@@ -1830,7 +1855,7 @@ impl Reader<'_> {
story,
freeform: None,
effects: Default::default(),
extra: Default::default(),
extra: ShapeExtra { cap, ..Default::default() },
});
}
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
@@ -1859,11 +1884,11 @@ impl Reader<'_> {
story: None,
freeform: Some(Arc::new(f)),
effects: Default::default(),
extra: Default::default(),
extra: ShapeExtra { cap, ..Default::default() },
});
}
let kind = kind?;
let mut extra = ShapeExtra::default();
let mut extra = ShapeExtra { cap, ..Default::default() };
if kind == ShapeKind::Line {
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
+2 -2
View File
@@ -1135,9 +1135,9 @@ fn write_comment_ids(
},
};
used.insert(durable.clone());
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
// Only a known UTC time: `w:date` is local time, often with a `Z` it doesn't mean.
// Remove date and time on save (#1062): no comment date here either.
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone() };
rows.push((pid.clone(), durable, utc));
}
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
+8 -6
View File
@@ -1097,7 +1097,8 @@ impl Writer<'_> {
match stroke {
Some(c) => {
let width = emu(stroke_width.clamp(0.0, 100.0));
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
let cap = if ink { "rnd" } else { extra.cap.ooxml() };
let ln: &[(&str, &str)] = if ink || !extra.cap.is_flat() { &[("w", width.as_str()), ("cap", cap)] } else { &[("w", width.as_str())] };
w.open("a:ln", ln);
// An outline gradient read from a file, while the outline keeps its colour (#1500).
let grad = extra.outline_fill.as_ref().filter(|f| !ink && f.main_color() == *c);
@@ -1122,7 +1123,9 @@ impl Writer<'_> {
w.close("a:ln");
}
None => {
w.open("a:ln", &[]);
let cap = extra.cap.ooxml();
let ln: &[(&str, &str)] = if extra.cap.is_flat() { &[] } else { &[("cap", cap)] };
w.open("a:ln", ln);
w.empty("a:noFill", &[]);
line_dash(w, &extra.dash);
w.close("a:ln");
@@ -1720,12 +1723,11 @@ pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
/// The heading may end a section: entries in columns follow a continuous break (#3889).
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
let start = bl.iter().position(|b| {
b.as_para().is_some_and(|p| {
p.section.is_none()
&& p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty()))
})
b.as_para()
.is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty())))
})?;
let entries = bl[start + 1..]
.iter()
+38 -4
View File
@@ -309,6 +309,16 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
parse_with(bytes, extra, false)
}
/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end
/// tag or an element left open at the end of the data is an error, not a cue to stop reading.
pub fn parse_strict(bytes: &[u8]) -> Result<El, DocxError> {
parse_with(bytes, &[], true)
}
fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result<El, DocxError> {
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
let transcoded = utf16_to_utf8(bytes);
let bytes = match &transcoded {
@@ -321,7 +331,7 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
c.trim_text_start = false;
c.trim_text_end = false;
c.expand_empty_elements = false;
c.check_end_names = false;
c.check_end_names = strict;
}
let mut stack: Vec<El> = Vec::new();
let mut root: Option<El> = None;
@@ -336,6 +346,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
};
match ev {
Event::Start(ref e) | Event::Empty(ref e) => {
if strict && root.is_some() && stack.is_empty() {
return Err(DocxError::Xml("more than one root element".into()));
}
count += 1;
if count > MAX_ELEMENTS {
return Err(DocxError::Limit("too many XML elements".into()));
@@ -423,18 +436,23 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
}
Err(_) => None,
};
if let Some(c) = ch {
push_text(top, &c);
match ch {
Some(c) => push_text(top, &c),
None if strict => return Err(DocxError::Xml("an entity is not defined".into())),
None => {}
}
}
}
Event::Eof => break,
_ => {}
}
if root.is_some() && stack.is_empty() {
if !strict && root.is_some() && stack.is_empty() {
break;
}
}
if strict && !stack.is_empty() {
return Err(DocxError::Xml("an element is not closed".into()));
}
// Unclosed elements at EOF: close them (lenient).
while let Some(el) = stack.pop() {
attach(&mut stack, &mut root, el);
@@ -554,6 +572,22 @@ pub fn body_ns() -> Vec<(String, String)> {
mod tests {
use super::*;
#[test]
fn strict_parsing_rejects_a_dropped_entity_and_trailing_markup() {
let bad = [&b"<w:t>before &unknown; after</w:t>"[..], b"<a></a><b></b>", b"<a></a><b>"];
for xml in bad {
assert!(parse_strict(xml).is_err(), "{}", String::from_utf8_lossy(xml));
}
assert!(
parse_strict(
b"<a>x &amp; y</a>
"
)
.is_ok()
);
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
}
#[test]
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
// The relationship namespace, and VML's `o:relid`.
+70
View File
@@ -683,6 +683,32 @@ fn a_style_defined_twice_is_its_last_definition() {
assert_eq!(h.para.align, None, "nothing kept from the first definition");
}
/// #3889: Word in another language writes the TOC styles under ids in that language (`toc 1` is
/// `Sumrio1` in Portuguese). They take the engine's ids, which Update Table and the writer know
/// the entries by, with their formatting and references; an id the file already uses stays.
#[test]
fn localized_toc_style_ids_take_the_engine_ids() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="CabealhodoSumrio"><w:name w:val="TOC Heading"/><w:next w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio1"><w:name w:val="toc 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:spacing w:after="100"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio2"><w:name w:val="toc 2"/><w:basedOn w:val="Sumrio1"/><w:pPr><w:ind w:left="220"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="TOC3"><w:name w:val="toc 3"/></w:style>
<w:style w:type="paragraph" w:styleId="Sumrio3"><w:name w:val="toc 3"/></w:style>
</w:styles>"#
);
let p = |st: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr><w:r><w:t>{st}</w:t></w:r></w:p>"#);
let body = ["CabealhodoSumrio", "Sumrio1", "Sumrio2", "Sumrio3"].map(p).concat();
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let ids: Vec<Option<&str>> = paras(&d).iter().map(|p| p.props.style.as_deref()).collect();
assert_eq!(ids, [Some("TOCHeading"), Some("TOC1"), Some("TOC2"), Some("Sumrio3")]);
let toc2 = d.styles.get("TOC2").unwrap();
assert_eq!((toc2.name.as_str(), toc2.based_on.as_deref(), toc2.para.indent_left), ("TOC 2", Some("TOC1"), Some(11.0)));
assert_eq!(d.styles.get("TOC1").unwrap().para.space_after, Some(5.0));
assert!(d.styles.get("Sumrio1").is_none() && d.styles.get("TOC3").is_some() && d.styles.get("Sumrio3").is_some());
}
#[test]
fn vml_image_and_inline_drawing() {
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
@@ -3687,6 +3713,50 @@ fn shape_dashes_import_and_save() {
}
}
/// Preserve explicit caps, default missing/unknown caps, and retain caps on unpainted outlines.
#[test]
fn shape_caps_import_and_round_trip() {
use wordcraft_doc::connector::LineCap;
for (attr, expected) in
[("", LineCap::Flat), ("flat", LineCap::Flat), ("sq", LineCap::Square), ("rnd", LineCap::Round), ("unknown", LineCap::Flat)]
{
for outline in [true, false] {
let cap = if attr.is_empty() { String::new() } else { format!(r#" cap="{attr}""#) };
let fill = if outline { r#"<a:solidFill><a:srgbClr val="C00000"/></a:solidFill>"# } else { "<a:noFill/>" };
let sppr = format!(r#"<a:prstGeom prst="line"/><a:noFill/><a:ln w="12700"{cap}>{fill}</a:ln>"#);
let body = anchored_wsp("Cap probe", 889000, 0, &sppr, "", "<wps:cNvSpPr/>");
let d = read_body(&format!("<w:p>{body}</w:p>"));
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
for doc in [&d, &saved] {
let InlineObject::Shape { extra, .. } = &paras(doc)[0].objects[0] else { panic!("shape") };
assert_eq!(extra.cap, expected, "{attr:?}, outline={outline}");
}
}
}
}
/// Preserve direct and shapetype-inherited VML end caps through a DrawingML save.
#[test]
fn vml_line_caps_import_and_round_trip() {
use wordcraft_doc::connector::LineCap;
for (attr, expected) in [("", LineCap::Flat), ("flat", LineCap::Flat), ("square", LineCap::Square), ("round", LineCap::Round)] {
for inherited in [false, true] {
let cap = if attr.is_empty() { String::new() } else { format!(r#" endcap="{attr}""#) };
let stroke = format!("<v:stroke{cap}/>");
let (base, own) = if inherited { (stroke.as_str(), "") } else { ("", stroke.as_str()) };
let body = format!(
r##"<w:p><w:r><w:pict><v:shapetype id="probe" o:spt="32">{base}</v:shapetype><v:shape type="#probe" style="width:40pt;height:1pt" strokecolor="#ff0000">{own}</v:shape></w:pict></w:r></w:p>"##
);
let d = read_body(&body);
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
for doc in [&d, &saved] {
let InlineObject::Shape { extra, .. } = &paras(doc)[0].objects[0] else { panic!("shape") };
assert_eq!(extra.cap, expected, "{attr:?}, inherited={inherited}");
}
}
}
}
#[test]
fn bookmark_end_name_and_id_trim() {
let body = r#"<w:p>
+19
View File
@@ -68,6 +68,25 @@ fn missing_or_wrong_main_part() {
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "ok");
}
fn body_zip(body: &str) -> Vec<u8> {
doc_zip(format!("<w:document {W_NS}><w:body>{body}</w:body></w:document>").as_bytes())
}
#[test]
fn a_malformed_main_part_is_an_error_not_a_truncated_document() {
let cases = [
("a less-than sign in a run", "<w:p><w:r><w:t>before a <- b after</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>"),
("a paragraph left open", "<w:p><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>second</w:t></w:r><w:p><w:r><w:t>third</w:t></w:r></w:p>"),
("a mismatched end tag", "<w:p><w:r><w:t>one</w:t></w:p></w:r><w:p><w:r><w:t>two</w:t></w:r></w:p>"),
];
for (what, body) in cases {
assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}");
}
let whole = "<w:p><w:r><w:t>a &lt;- b</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>";
let d = read(&body_zip(whole)).unwrap();
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "a <- b\nlast", "well-formed text still reads whole");
}
#[test]
fn deep_nesting_is_rejected() {
let mut x = format!("<w:document {W_NS}><w:body>");
+15
View File
@@ -249,6 +249,21 @@ fn comments_without_durable_ids_get_unique_ones() {
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
}
/// A local `w:date` with a `Z` (older files have no `dateUtc`) isn't saved as the UTC time either,
/// so it doesn't shift by the user's offset after a round trip.
#[test]
fn a_local_date_with_a_z_is_not_saved_as_utc() {
let mut doc = wordcraft_docx::read(&comments_package(false)).unwrap();
for c in doc.comments.values_mut() {
c.date.push('Z');
}
let files = save(&doc);
let (_, dates) = written_ids(&files);
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
assert!(back.comments.values().all(|c| c.date_utc.is_none() && c.date.ends_with('Z')));
}
#[test]
fn comment_references_use_the_documents_annotation_reference_style() {
let files = save(&wordcraft_docx::read(&comments_package(true)).unwrap());
+1 -1
View File
@@ -9,7 +9,7 @@ description = "WordCraft engine: session, selection, undo, the command registry
workspace = true
[dependencies]
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
+16
View File
@@ -346,6 +346,9 @@ View|Window|Switch Windows|view.switchWindows
View|Macros|Macros|tools.macros
View|Macros|Record Macro|tools.recordMacro
View|SharePoint|Properties|file.properties
Read Mode|View|Column Width|view.readColumnWidth
Read Mode|View|Page Color|view.readPageColor
Read Mode|View|Layout|view.readLayout
Developer|Controls|Rich Text Content Control|developer.richText
Developer|Controls|Plain Text Content Control|developer.plainText
Developer|Controls|Picture Content Control|developer.picture
@@ -591,4 +594,17 @@ mod tests {
// The floor only ever rises.
assert!(pct >= 90.4, "parity {pct}%");
}
#[test]
fn read_mode_view_menu_is_catalogued() {
let e = entries();
let read_mode: Vec<_> = e.iter().filter(|x| x.0 == "Read Mode").map(|x| (x.1, x.2, x.3)).collect();
assert_eq!(
read_mode,
[("View", "Column Width", "view.readColumnWidth"), ("View", "Page Color", "view.readPageColor"), ("View", "Layout", "view.readLayout")]
);
let reg = crate::cmd::registry();
assert!(reg.get("view.readColumnWidth").is_some(), "Column Width counts as live");
assert!(reg.get("view.readPageColor").is_some(), "Page Color counts as live");
}
}
+20 -1
View File
@@ -286,7 +286,7 @@ fn content_base(s: &Session, r: &ControlRange) -> CharProps {
/// and stops showing it. Returns the formatting the text should get, when it changes.
pub fn prepare_edit(s: &mut Session) -> Result<Option<CharProps>, CmdError> {
if s.sel.is_collapsed() {
let at = s.doc.typing_pos(&s.sel.focus);
let at = super::protect::typing_pos(s, &s.doc.typing_pos(&s.sel.focus));
if at != s.sel.focus {
s.sel = Selection::caret(at);
}
@@ -864,6 +864,25 @@ mod editing_tests {
assert_eq!(typed, None, "a placeholder style localized in id and name");
}
/// Typed text with line breaks in it (`type_text` with paragraphs, a typed paste) is the
/// Enter key pressed between the lines, so a one-line control, where Enter does nothing, stays
/// one paragraph, and a multi-line one splits (#3973: the line breaks went in as one command
/// and split a one-line control).
#[test]
fn typed_line_breaks_do_not_split_a_one_line_control() {
for (multi_line, paragraphs) in [(false, 1), (true, 2)] {
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "text.insert", json!({"text": "Name: "}));
run(&mut s, "developer.plainText", json!({"title": "Name", "tag": "name"}));
run(&mut s, "developer.properties", json!({"control": "name", "multiLine": multi_line}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "text.insert", json!({"text": "Ada\rLovelace", "raw": true}));
assert_eq!(s.doc.body.len(), paragraphs, "multi-line control: {multi_line}");
let r = s.doc.control_at(&s.sel.focus).unwrap();
assert_eq!(r.control.tag, "name", "still inside the control");
}
}
#[test]
fn typing_replaces_the_placeholder_and_emptying_brings_it_back() {
let mut s = Session::new(wordcraft_doc::Document::new());
+19
View File
@@ -532,6 +532,16 @@ fn keep_lasso(s: &mut Session, sel: LassoSelection) {
s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel });
}
/// After a command that changed nothing, which puts the revision back from `touched` to `rev`: a
/// lasso selection the command kept for the revision it would have reached stays selected.
pub(crate) fn rekey_lasso(s: &mut Session, touched: u64, rev: u64) {
if let Some(l) = s.view.draw.lasso.as_mut()
&& (l.rev == touched || l.rev == touched.wrapping_add(1))
{
l.rev = rev;
}
}
/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule).
fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool {
let n = poly.len();
@@ -1081,6 +1091,15 @@ mod tests {
let objs = ink(&s);
let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count();
assert_eq!(blue, 1);
// Recolouring to the colour it has changes nothing, and the selection stays; an edit
// elsewhere ends it.
s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap();
assert_eq!(s.undo_depth(), steps + 2);
assert_eq!(super::lasso_selection(&s).map(|l| l.items.clone()), Some(vec![sel.clone()]));
s.run("text.insert", &json!({"text": "x"})).unwrap();
assert!(super::lasso_selection(&s).is_none());
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [260, 80], [260, 180], [80, 180]]})).unwrap();
assert_eq!(r["count"], 1);
// Deleted: only the selected stroke goes, and the selection with it.
s.run("draw.lassoDelete", &json!({})).unwrap();
assert_eq!(ink(&s).len(), 1);
+77 -13
View File
@@ -13,6 +13,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list.
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
/// Register character-formatting commands, their parameters and keyboard bindings.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
.params(
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
r#"{"color"?: "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"} (explicit colour sets it; omitted toggles the last chosen colour, initially yellow)"#,
)
// ⌘⌥H hides other apps on macOS.
.key(super::non_mac("Mod+Alt+H")),
@@ -228,7 +229,8 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
sel_result(s)
}
/// Resolved formatting of every character in the target (or the caret).
/// Resolved target formatting, sampled up to a paragraph/column-row boundary after 10,000 runs.
/// Empty targets use pending caret formatting or the selected text box paragraph mark.
fn resolved(s: &Session) -> Vec<ResolvedChar> {
let mut out = Vec::new();
if let Some(rows) = column_rows(s) {
@@ -391,18 +393,80 @@ fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
apply(s, &|x| x.underline_color = rgb)
}
/// Test the entire highlight target without the formatting-state query's run cap.
/// Resolve one run at a time and stop at the first mismatch, without collecting them.
fn target_has_highlight(s: &Session, h: Highlight) -> bool {
let matches = |style: Option<&str>, props: &CharProps| s.doc.styles.resolve_char(style, props).highlight == h.rgb();
let mut seen = false;
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
let Some(p) = s.doc.para_at(&a) else { continue };
if a == b || (a.off == 0 && b.off == p.len()) {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > a.off && r.start < b.off {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
}
}
if seen {
return true;
}
}
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
for path in s.doc.paths_between(&a, &b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
// Match format_range: fully selected paragraphs include their mark.
if to == p.len() && (from == 0 || path != a.path) {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
}
}
}
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
}
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
/// the last choice off only when the whole formatting target already has that colour.
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
let c = p::str(v, "color").unwrap_or("yellow");
let h = Highlight::ALL
.iter()
.copied()
.find(|h| {
h.ooxml().eq_ignore_ascii_case(c)
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|| format!("{h:?}").eq_ignore_ascii_case(c)
})
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
apply(s, &|x| x.highlight = Some(h))
let color = v.get("color").map(|_| p::req_str(v, "color")).transpose()?;
let h = match color {
None => s.last_highlight,
Some(c) => Highlight::ALL
.iter()
.copied()
.find(|h| {
h.ooxml().eq_ignore_ascii_case(c)
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|| format!("{h:?}").eq_ignore_ascii_case(c)
})
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?,
};
let remove = color.is_none() && target_has_highlight(s, h);
let value = if remove { Highlight::None } else { h };
let result = apply(s, &|x| x.highlight = Some(value))?;
s.last_highlight = h;
Ok(result)
}
/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle
+41 -10
View File
@@ -227,8 +227,8 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
}
/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it
/// (TIFF included: the `image` crate decodes it).
/// The media extension of an encoded image by its magic bytes, as the picture probe accepted it
/// (TIFF included when its compression is one the `image` crate decodes within limits).
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
match bytes.get(..4) {
Some([0x89, b'P', b'N', b'G']) => "png",
@@ -249,14 +249,15 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
if bytes.len() > 200 << 20 {
return Err(CmdError::Failed("image is larger than 200 MB".into()));
}
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
let ext = image_ext(&bytes);
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
size
} else {
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). A picture that
// is never decoded (a TIFF with JPEG strips, #3645) isn't inserted.
let (natural_w, natural_h, ext) = match wordcraft_geom::picture::probe(&bytes) {
Ok(pic) => (pic.width as f32 * 0.75, pic.height as f32 * 0.75, image_ext(&bytes)),
Err(wordcraft_geom::picture::Refusal::Unsupported(why)) => return Err(CmdError::Failed(why.into())),
Err(_) => match wordcraft_render::metafile_size(&bytes) {
Some(size) => size,
None => return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into())),
},
};
let key = s.doc.add_media(bytes, ext);
// Fitted to the text width.
@@ -1050,6 +1051,36 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
mod tests {
use super::*;
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
fn jpeg_strip_tiff() -> Vec<u8> {
let mut t = Vec::new();
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut t), image::ImageFormat::Tiff).unwrap();
assert_eq!(&t[..4], b"II*\0");
let ifd = u32::from_le_bytes(t[4..8].try_into().unwrap()) as usize;
let n = usize::from(u16::from_le_bytes([t[ifd], t[ifd + 1]]));
let e = (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| t[e..e + 2] == 259u16.to_le_bytes()).unwrap();
t[e + 8..e + 10].copy_from_slice(&7u16.to_le_bytes());
t
}
/// #3645: a TIFF with JPEG strips is never decoded, so it is neither inserted nor swapped in;
/// before, its readable header was all insertion looked at.
#[test]
fn tiffs_with_jpeg_strips_are_refused() {
let tiff = jpeg_strip_tiff();
assert_eq!(wordcraft_render::image_size(&tiff), Some((4, 4)), "its header reads");
let mut s = Session::new(wordcraft_doc::Document::new());
let e = s.run("insert.picture", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
assert!(e.contains("TIFF"), "{e}");
assert!(s.doc.media.is_empty());
let mut plain = Vec::new();
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut plain), image::ImageFormat::Tiff).unwrap();
s.run("insert.picture", &json!({"data": base64_encode(&plain)})).unwrap();
let e = s.run("picture.change", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
assert!(e.contains("TIFF"), "{e}");
assert_eq!(s.doc.media.len(), 1);
}
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
/// body, not on page 1 (#3331).
#[test]
+39
View File
@@ -1501,6 +1501,45 @@ mod tests {
assert_eq!(parse_csv("a;b\n1;2")[1], vec!["1", "2"]);
}
/// #4001: finishing a merge opens a new document, with a new undo history. It is not an
/// edit of the open document, even when the merged document holds the same content.
#[test]
fn finishing_a_merge_opens_a_new_document_with_a_new_history() {
let mut s = Session::new(Document::from_text("Hello"));
s.run("text.insert", &json!({"text": "!"})).unwrap();
assert_eq!(s.undo_depth(), 1);
s.run("caret.docStart", &json!({})).unwrap();
// One recipient and no merge fields: the merged document holds the same content.
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
let id = s.document_id();
s.run("mailings.finish", &json!({})).unwrap();
assert_ne!(s.document_id(), id, "the document was replaced");
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello");
assert_eq!(s.undo_depth(), 0, "the new document starts with an empty undo history");
// Undo cannot bring the old document's text back.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello", "the old text does not come back");
}
/// #4004: finishing a merge to a file saves the merged copy there and leaves the open
/// document alone: it changes nothing, so it is not an edit.
#[test]
fn finishing_a_merge_to_a_file_is_not_an_edit() {
let dir = std::env::temp_dir().join(format!("wordcraft-merge-to-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("merged.docx");
let mut s = Session::new(Document::from_text("Hello"));
s.run("text.insert", &json!({"text": "!"})).unwrap();
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
let (depth, dirty, text) = (s.undo_depth(), s.dirty, s.doc.plain_text(StoryRef::Body));
s.run("mailings.finish", &json!({"path": out.to_string_lossy()})).unwrap();
assert!(out.exists(), "the merged copy was written");
assert_eq!(s.doc.plain_text(StoryRef::Body), text, "the open document is unchanged");
assert_eq!(s.undo_depth(), depth, "no undo step");
assert_eq!(s.dirty, dirty, "the unsaved-changes flag is unchanged");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn csv_delimiter_ignores_quoted_text() {
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
+5 -1
View File
@@ -311,8 +311,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
}
let mut at = s.sel.focus.clone();
let mut first = true;
// Typed line breaks are the Enter key between the lines: in a one-line control it does
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
// its breaks, as before).
let no_breaks = enter && controls::refuses_paragraph(s);
for part in text.split(['\r', '\u{2029}']) {
if !first {
if !first && !no_breaks {
at = split_para_as(s, &at, enter)?;
}
first = false;
+232 -55
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
// `blur` asserts on a subnormal width.
img = img.blur(-sharpen / 25.0);
}
img
Ok(img)
})
})
.params(r#"{"brightness"?: -100..100, "contrast"?: -100..100, "sharpen"?: -100..100}"#)
@@ -146,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| recolor(img, &mode, sat))
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
for px in rgba.pixels_mut() {
px.0 = [c.0, c.1, c.2, px.0[3]];
}
image::DynamicImage::ImageRgba8(rgba)
Ok(image::DynamicImage::ImageRgba8(rgba))
})
})
.params(r#"{"color": "RRGGBB"}"#)
@@ -186,20 +186,20 @@ pub fn specs() -> Vec<CommandSpec> {
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
}
image::DynamicImage::ImageRgba8(rgba)
Ok(image::DynamicImage::ImageRgba8(rgba))
})
})
.params(r#"{"percent": 0..100}"#)
.when(has_picture),
CommandSpec::new("picture.removeBackground", "Remove Background", "Picture Format › Adjust", |s, v| {
let tol = p::f32(v, "tolerance").unwrap_or(30.0).clamp(0.0, 255.0);
adjust(s, move |img| remove_background(img, tol))
adjust(s, move |img| Ok(remove_background(img, tol)))
})
.params(r#"{"tolerance"?: 0..255}"#)
.when(has_picture),
CommandSpec::new("picture.compress", "Compress Pictures", "Picture Format › Adjust", |s, v| {
let max = p::u64(v, "maxPixels").unwrap_or(1600).clamp(32, 20000) as u32;
adjust(s, move |img| if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img })
adjust(s, move |img| Ok(if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img }))
})
.params(r#"{"maxPixels"?: n}"#)
.when(has_picture),
@@ -212,7 +212,10 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes));
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
@@ -1182,7 +1185,7 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
}
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
adjust_media(s, &media, f)
@@ -1191,13 +1194,14 @@ fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImag
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
return Err(CmdError::Failed("picture is too large to edit".into()));
}
let out = f(img);
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
wordcraft_geom::picture::Refusal::TooLarge { .. } => too_large(),
e => CmdError::Failed(format!("can't decode the picture: {e}")),
})?;
let out = f(img)?;
let mut png = Vec::new();
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
let key = s.doc.add_media(png, "png");
@@ -1221,6 +1225,25 @@ fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) ->
})
}
fn too_large() -> CmdError {
CmdError::Failed("picture is too large to edit".into())
}
/// A `w` × `h` bitmap of `fill` for an edit to draw on, refused when the picture it makes would be
/// too large to draw (two RGBA buffers within the decode budget), before it is allocated (#3611).
fn canvas(w: u32, h: u32, fill: [u8; 4]) -> Result<image::RgbaImage, CmdError> {
if !wordcraft_geom::decode_fits(w, h, wordcraft_geom::MAX_DECODED_BYTES) {
return Err(too_large());
}
Ok(image::RgbaImage::from_pixel(w, h, image::Rgba(fill)))
}
/// `w` and `h` grown by `pad` on every side, None past `u32`.
fn grown(w: u32, h: u32, pad: u32) -> Option<(u32, u32)> {
let twice = pad.checked_mul(2)?;
Some((w.checked_add(twice)?, h.checked_add(twice)?))
}
/// What Artistic Effects, Picture Styles and Picture Border drew on a picture's bitmap: the
/// bitmap they started from and the effect, style and border on it, so the next one draws again
/// from that bitmap instead of over the last result. Picking a second style replaces the first,
@@ -1267,12 +1290,12 @@ fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) -> CmdResult {
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st);
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255]);
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
img
Ok(img)
})?;
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
&& let Some(drawn) = s.doc.media.get(&media).cloned()
@@ -1356,12 +1379,22 @@ fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImag
/// Flood the border-connected region close to the corner colour with transparency.
fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
let mut rgba = img.to_rgba8();
clear_background(&mut rgba, tol);
image::DynamicImage::ImageRgba8(rgba)
}
/// [`remove_background`] on `rgba`, a row span at a time: besides the border's seeds, the stack
/// holds one for each run of matching pixels beside a filled span, instead of four for every
/// pixel filled (#3647). Returns the most seeds it held.
fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
let (w, h) = rgba.dimensions();
if w == 0 || h == 0 {
return image::DynamicImage::ImageRgba8(rgba);
}
let bg = rgba.get_pixel(0, 0).0;
let Some(bg) = rgba.get_pixel_checked(0, 0).map(|p| p.0) else { return 0 };
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
let at = |x: u32, y: u32| y as usize * w as usize + x as usize;
// Not cleared yet, and close to the corner colour.
let open = |rgba: &image::RgbaImage, seen: &[bool], x: u32, y: u32| {
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
};
let mut seen = vec![false; (w as usize) * (h as usize)];
let mut stack: Vec<(u32, u32)> = Vec::new();
for x in 0..w {
@@ -1372,46 +1405,55 @@ fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage
stack.push((0, y));
stack.push((w - 1, y));
}
let mut most = stack.len();
while let Some((x, y)) = stack.pop() {
let i = (y as usize) * (w as usize) + x as usize;
if seen.get(i).copied().unwrap_or(true) {
if !open(rgba, &seen, x, y) {
continue;
}
if let Some(sv) = seen.get_mut(i) {
*sv = true;
let (mut x0, mut x1) = (x, x);
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
x0 -= 1;
}
let p = rgba.get_pixel(x, y).0;
if !close(p) {
continue;
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
x1 += 1;
}
rgba.put_pixel(x, y, image::Rgba([p[0], p[1], p[2], 0]));
if x > 0 {
stack.push((x - 1, y));
for xi in x0..=x1 {
if let Some(s) = seen.get_mut(at(xi, y)) {
*s = true;
}
if let Some(p) = rgba.get_pixel_mut_checked(xi, y) {
p.0[3] = 0;
}
}
if x + 1 < w {
stack.push((x + 1, y));
}
if y > 0 {
stack.push((x, y - 1));
}
if y + 1 < h {
stack.push((x, y + 1));
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
let mut xi = x0;
while xi <= x1 {
if open(rgba, &seen, xi, ny) {
stack.push((xi, ny));
while xi <= x1 && open(rgba, &seen, xi, ny) {
xi += 1;
}
} else {
xi += 1;
}
}
}
most = most.max(stack.len());
}
image::DynamicImage::ImageRgba8(rgba)
most
}
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> image::DynamicImage {
let (w, h) = (img.width(), img.height());
let mut out = image::RgbaImage::from_pixel(w + 2 * border, h + 2 * border, image::Rgba(color));
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
let mut out = canvas(w, h, color)?;
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
image::DynamicImage::ImageRgba8(out)
Ok(image::DynamicImage::ImageRgba8(out))
}
fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
fn picture_style(img: image::DynamicImage, style: &str) -> Result<image::DynamicImage, CmdError> {
let m = (img.width().max(img.height()) / 40).max(4);
match style {
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255]), m / 2, [40, 40, 40, 255]),
Ok(match style {
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255])?, m / 2, [40, 40, 40, 255])?,
"rounded" | "softEdge" => {
let mut rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
@@ -1434,18 +1476,47 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
"shadow" => {
let (w, h) = (img.width(), img.height());
let off = m;
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
let shadow = image::DynamicImage::ImageRgba8(shadow);
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
image::imageops::overlay(&mut padded, &shadow.to_rgba8(), (off + off / 2) as i64, (off + off / 2) as i64);
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
image::imageops::overlay(&mut out, &blurred, 0, 0);
let (pw, ph) = grown(w, h, off).ok_or_else(too_large)?;
let mut out = canvas(pw, ph, [0, 0, 0, 0])?;
// A rectangle of alpha 90 the picture's size, one and a half margins in, blurred. Its
// blur is the product of its sides' blurs along each axis, so it is drawn from two
// profiles, not by blurring every pixel with a kernel that grows with the picture (#3647).
let (sigma, start) = (off as f32 / 2.0, off + off / 2);
let (xs, ys) = (blurred_span(pw, start, w, sigma), blurred_span(ph, start, h, sigma));
for (x, y, p) in out.enumerate_pixels_mut() {
let k = xs.get(x as usize).copied().unwrap_or(0.0) * ys.get(y as usize).copied().unwrap_or(0.0);
p.0[3] = (90.0 * k).round() as u8;
}
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
image::DynamicImage::ImageRgba8(out)
}
_ => frame(img, m, [255, 255, 255, 255]),
_ => frame(img, m, [255, 255, 255, 255])?,
})
}
/// How much of a span `len` long from `start` covers each of `n` positions once blurred with the
/// Gaussian `image::imageops::blur` uses for `sigma` (outside the `n` positions counts as clear):
/// 0 to 1. Each position is one difference of the kernel's running sums.
fn blurred_span(n: u32, start: u32, len: u32, sigma: f32) -> Vec<f32> {
let kernel = gaussian_kernel(sigma);
let mut sums = vec![0.0f32];
for k in &kernel {
sums.push(sums.last().copied().unwrap_or(0.0) + k);
}
let (size, r) = (kernel.len() as i64, (kernel.len() / 2) as i64);
let sum_to = |k: i64| sums.get(k.clamp(0, size) as usize).copied().unwrap_or(0.0);
let (start, end) = (i64::from(start), i64::from(start) + i64::from(len));
// Position i takes weight k from i + k - r, inside the span for k in [start - i + r, end - i + r).
(0..i64::from(n)).map(|i| sum_to(end - i + r) - sum_to(start - i + r)).collect()
}
/// The normalized kernel `image::imageops::blur` (0.25) uses for `sigma`: odd, about 6.7 σ wide.
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
let size = ((((sigma - 0.8) / 0.3 + 1.0) * 2.0 + 1.0).max(3.0) as usize) | 1;
let mean = (size / 2) as f32;
let weights: Vec<f32> = (0..size).map(|x| (-0.5 * ((x as f32 - mean) / sigma).powi(2)).exp()).collect();
let total: f32 = weights.iter().sum();
weights.into_iter().map(|v| v / total).collect()
}
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
@@ -1982,6 +2053,112 @@ mod tests {
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1));
}
/// A PNG whose header (valid CRC) declares `w` × `h` pixels, with one pixel of data.
fn header_only_png(w: u32, h: u32) -> Vec<u8> {
let mut b = png(8, 8);
b[16..20].copy_from_slice(&w.to_be_bytes());
b[20..24].copy_from_slice(&h.to_be_bytes());
let mut crc = 0xFFFF_FFFFu32;
for &byte in &b[12..29] {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
}
}
b[29..33].copy_from_slice(&(!crc).to_be_bytes());
b
}
#[test]
fn picture_edits_refuse_an_over_budget_header_before_decoding() {
// Inserting reads the size only; every edit then refuses it from the header (#3644, #3647).
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&header_only_png(20_000, 20_000))})).unwrap();
let media = s.doc.media.len();
for (id, v) in [
("picture.corrections", json!({"brightness": 10})),
("picture.color", json!({"mode": "sepia"})),
("picture.removeBackground", json!({})),
("picture.style", json!({"style": "shadow"})),
] {
let e = s.run(id, &v).unwrap_err().to_string();
assert!(e.contains("too large to edit"), "{id}: {e}");
}
assert_eq!(s.doc.media.len(), media, "nothing was added");
}
/// The Shadow style before #3647: the rectangle blurred with `image`'s Gaussian.
fn blurred_shadow(img: &image::DynamicImage) -> image::RgbaImage {
let (w, h) = (img.width(), img.height());
let off = (w.max(h) / 40).max(4);
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
image::imageops::overlay(&mut padded, &shadow, (off + off / 2) as i64, (off + off / 2) as i64);
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
image::imageops::overlay(&mut out, &blurred, 0, 0);
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
out
}
/// #3647: the shadow drawn from two profiles looks like the blur it replaces.
#[test]
fn the_shadow_style_matches_the_blur_it_replaces() {
for (w, h) in [(80, 40), (400, 220), (37, 300)] {
let img = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, y| {
image::Rgba([(x * 7) as u8, (y * 5) as u8, 90, if (x + y) % 3 == 0 { 255 } else { 120 }])
}));
let new = picture_style(img.clone(), "shadow").unwrap().to_rgba8();
let old = blurred_shadow(&img);
assert_eq!(new.dimensions(), old.dimensions());
let worst = new.pixels().zip(old.pixels()).flat_map(|(a, b)| a.0.into_iter().zip(b.0).map(|(a, b)| a.abs_diff(b))).max();
assert!(worst.is_some_and(|d| d <= 2), "{w} x {h}: {worst:?}");
}
}
/// #3647: Remove Background fills a span at a time, so its stack stays near the border's seeds
/// instead of growing with the picture, and it clears the same region.
#[test]
fn remove_background_keeps_a_small_stack() {
let mut uniform = image::RgbaImage::from_pixel(2000, 2000, image::Rgba([250, 250, 250, 255]));
let most = clear_background(&mut uniform, 30.0);
assert!(most <= 4 * (2000 + 2000) + 8, "{most}");
assert!(uniform.pixels().all(|p| p.0[3] == 0));
// A square ring keeps what it encloses: only background reachable from the border goes.
let mut ring = image::RgbaImage::from_fn(60, 60, |x, y| {
let d = (x as i32 - 30).abs().max((y as i32 - 30).abs());
if (10..14).contains(&d) { image::Rgba([200, 20, 20, 255]) } else { image::Rgba([255, 255, 255, 255]) }
});
clear_background(&mut ring, 30.0);
assert_eq!(ring.get_pixel(0, 0).0, [255, 255, 255, 0], "outside: cleared, colour kept");
assert_eq!(ring.get_pixel(5, 30).0[3], 0);
assert_eq!(ring.get_pixel(30, 18).0[3], 255, "the ring");
assert_eq!(ring.get_pixel(30, 30).0[3], 255, "inside the ring");
assert_eq!(clear_background(&mut image::RgbaImage::new(0, 0), 30.0), 0);
}
/// #3611: a style or border is drawn only when the picture it makes can still be drawn. On a
/// 1 × 2,000,000 picture Thick Frame asked for about 1.8 TB and aborted the process.
#[test]
fn picture_styles_and_borders_check_the_size_they_draw() {
let mut thin = Vec::new();
image::GrayImage::new(1, 2_000_000).write_to(&mut std::io::Cursor::new(&mut thin), image::ImageFormat::Png).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&thin)})).unwrap();
let media = s.doc.media.len();
for (id, v) in [
("picture.style", json!({"style": "thickFrame"})),
("picture.style", json!({"style": "shadow"})),
("picture.style", json!({})),
("picture.border", json!({"width": 200})),
] {
let e = s.run(id, &v).unwrap_err().to_string();
assert!(e.contains("too large to edit"), "{id} {v}: {e}");
}
assert_eq!(s.doc.media.len(), media, "nothing was drawn");
assert_eq!(grown(u32::MAX, 1, 1), None);
}
#[test]
fn picture_pipeline() {
let mut s = Session::new(wordcraft_doc::Document::new());
+2 -2
View File
@@ -398,7 +398,7 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
};
let para = s.doc.para_mut(a.story, &path)?;
match num {
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) }),
None => {
para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab);
para.props.cancel_char_indents(true, false, false);
@@ -810,7 +810,7 @@ fn outline_step(s: &mut Session, promote: bool) -> CmdResult {
let list = p.props.numbering.filter(|n| n.num != 0);
let target = match (list, heading_level(s, &p.props)) {
(Some(n), _) => {
let level = if promote { n.level.saturating_sub(1) } else { (n.level + 1).min(8) };
let level = if promote { n.level.saturating_sub(1) } else { n.level.saturating_add(1).min(8) };
if level != n.level {
let para = s.doc.para_mut(a.story, &path)?;
para.props.numbering = Some(NumRef { num: n.num, level });
+72
View File
@@ -637,6 +637,35 @@ pub(crate) fn protection_lifted(before: &Document, after: &Document) -> bool {
b.protection.is_some() && (a.protection != b.protection || a.protect_formatting != b.protect_formatting || a.protect_hash != b.protect_hash)
}
/// Where typing at the caret `p` goes in a document that is read only or open to comments only: a
/// caret right before the start of an exception this author may edit goes inside it, and one right
/// after its end goes back inside it. The markers take no room, so the start of a line that an
/// exception starts, and the end of a line that one ends, are those carets.
pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
return p.clone();
}
let Some(para) = s.doc.para_at(p) else { return p.clone() };
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let w = OBJ.len_utf8();
let mut off = p.off;
if is_start(off) {
while is_start(off) {
off += w;
}
return Pos { off, ..p.clone() };
}
while off >= w && is_end(off - w) {
off -= w;
}
Pos { off, ..p.clone() }
}
/// The first position in an editable range after `from` (wrapping round), for Find Next Region
/// I Can Edit; with it, the range's end. Filling in forms: the next fillable control's content.
pub fn next_region(s: &Session, from: &Pos) -> Option<(Pos, Pos)> {
@@ -689,6 +718,49 @@ mod tests {
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
}
/// An exception that starts a line: Home, Left and Down put the caret before its start
/// marker, and Enter and typing there go inside the exception. A caret after the end marker at
/// the end of the line goes back inside it too.
#[test]
fn typing_at_the_start_or_end_of_an_exception_line_goes_inside_it() {
let visible = |s: &Session| s.doc.plain_text(StoryRef::Body).replace(OBJ, "");
let mut s = Session::new(Document::from_text("Title\nBody text\nEnd."));
select(&mut s, "Body text");
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.run("caret.home", &json!({})).unwrap();
assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret is before the start marker");
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "New "})).unwrap();
assert_eq!(visible(&s), "Title\n\nNew Body text\nEnd.");
s.run("caret.end", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "!"})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
assert_eq!(visible(&s), "Title\n\nNew Body text!\n\nEnd.");
// Outside the exception stays refused: the start of "Title" and the end of "End.".
for at in [Pos::body(0, 0), s.doc.end_of(StoryRef::Body)] {
s.sel = Selection::caret(at);
assert!(s.run("text.newParagraph", &json!({})).is_err());
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
}
// An empty editable line right after a protected short line, with Built-in Heading styles
// on: two Enters break the paragraph, and the protected line stays Normal text.
let mut s = Session::new(Document::from_text("Title\n\nEnd."));
s.run("tools.autocorrect", &json!({"headingStyles": true})).unwrap();
s.sel = Selection { anchor: Pos::body(1, 0), focus: Pos::body(2, 0) };
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.sel = Selection::caret(Pos::body(0, 2));
s.run("caret.down", &json!({})).unwrap();
assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret is before the start marker");
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Notes"})).unwrap();
assert_eq!(visible(&s), "Title\n\n\nNotes\nEnd.");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.is_none()));
}
/// Table, row and cell formatting reaches protected text, so an exception inside one cell
/// doesn't let table commands run there change the table, the row or the cell (#1327).
#[test]
+286 -32
View File
@@ -21,9 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
if let Some((start, end)) = toc_range(s) {
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
remove_toc(s, start, end)?;
s.clamp_selection();
}
sel_result(s)
@@ -339,6 +337,36 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
Some((start, end))
}
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
/// with the table.
fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
Ok(p) if p.section.is_some() => {
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
start + 1
}
_ => {
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
start
}
};
let titled = start
.checked_sub(1)
.and_then(|i| s.doc.body.get(i))
.and_then(|b| b.as_para())
.is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none());
if titled {
s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?;
return Ok(at - 1);
}
Ok(at)
}
/// The caret is in the table of contents (its heading or an entry), where Word's context menu
/// offers Update Field for the whole table.
pub fn caret_in_toc(s: &Session) -> bool {
@@ -393,15 +421,24 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
let i = at.path.last();
// Or one holding only the end of Word's TOC control, still styled as an entry: Update Table
// would delete it with the old entries anyway.
let leftover = |p: &Paragraph| {
p.is_empty()
|| (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd))
&& p.text.chars().all(|c| c == OBJ)
&& p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
};
if at.path.depth() == 0
&& i > 0
&& s.doc.para_at(&at).is_some_and(|p| p.is_empty())
&& s.doc.para_at(&at).is_some_and(leftover)
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
// field was (the field is saved opening in the first entry), so the next save and update
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
// range TOC is reused the same way, so deleting it later deletes the TOC field.
// range TOC is reused the same way, so deleting it later deletes the TOC field. So is one
// ending the section before, as above entries in columns (#3889).
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty() && p.section.is_none())
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty())
})
{
s.doc.remove_block(StoryRef::Body, &at.path)?;
@@ -474,9 +511,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
return sel_result(s);
}
Some((start, end)) => {
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
let start = remove_toc(s, start, end)?;
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
placeholder = true;
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 });
@@ -585,10 +620,13 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
// The text width of the TOC's own section, not the caret's: after Select All the caret sits
// at the end of the document, possibly in a wider (landscape) section.
let section = s.doc.section_index_of(start);
let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width());
// The column width of the TOC's own section, not the caret's: after Select All the caret sits
// at the end of the document, possibly in a wider (landscape) section. In columns, the first
// column's, as Word does (#3889); after a field paragraph ending a section, the next section's.
let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some());
let section = s.doc.section_index_of(start + usize::from(after_break));
let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w);
let width = s.doc.sections().get(section).map_or_else(|| column(&super::page::sect(s)), |(_, x)| column(x));
let entries = toc_entries(s, &f);
let links: Vec<Option<String>> = if f.has('h') {
let mut v = Vec::new();
@@ -902,12 +940,12 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
n.0 = n.0.saturating_add(1);
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
updates.push((k, if f.has('h') { String::new() } else { fmt.format(n.0) }));
updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }));
}
"STYLEREF" => {
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
if let Some(i) = ix.index_of(&path) {
updates.push((k, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))));
updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
}
}
_ => {}
@@ -937,10 +975,11 @@ fn has_switch(instr: &str, sw: char) -> bool {
})
}
/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, TITLE, AUTHOR
/// and NUMWORDS in the body, or in the header, footer, note or text box the caret is in (the body's
/// fields stay as they are then). SEQ numbering, citations and TOC are body-only, so they run only
/// when the caret is in the body.
/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, the document
/// information fields (TITLE, SUBJECT, AUTHOR, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM, NUMWORDS,
/// NUMCHARS, FILENAME, DOCPROPERTY) in the body, or in the header, footer, note or text box the
/// caret is in (the body's fields stay as they are then). SEQ numbering, citations and TOC are
/// body-only, so they run only when the caret is in the body.
pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
update_fields_in(s, false)
}
@@ -970,6 +1009,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
}
let stories: Vec<StoryRef> =
if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] };
// 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();
for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::<Vec<_>>() {
let Some(p) = s.doc.para(story, &path) else { continue };
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
@@ -996,7 +1038,7 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
match name.as_str() {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, super::insert::format_date(&pic)));
updates.push((k, text_result(instr, super::insert::format_date(&pic))));
}
"REF" | "PAGEREF" => {
let f = FieldCode::parse(instr);
@@ -1006,34 +1048,48 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
} else if !has_switch(instr, 'p') {
pages.get(target).map(|(num, page_fmt)| {
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(*page_fmt);
fmt.format(*num)
wordcraft_layout::fields::field_number(instr, fmt, *num)
})
} else {
None
};
if let Some(r) = r {
updates.push((k, r));
updates.push((k, if name == "REF" { text_result(instr, r) } else { r }));
}
}
"TITLE" => updates.push((k, s.doc.core.title.clone())),
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
// The summary properties; a quoted text after the name replaces the property.
"TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => {
if let Some(v) = new_property_value(instr) {
set_summary_property(&mut core, &name, v);
}
updates.push((k, text_result(instr, summary_property(&core, &name).to_string())));
}
"LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))),
"NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))),
"NUMCHARS" => {
let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count();
updates.push((k, count_text(instr, chars)));
}
"REVNUM" => updates.push((k, count_text(instr, core.revision as usize))),
// DOCPROPERTY <name>: a custom property first, then a built-in one (#1798).
"DOCPROPERTY" => {
if let Some(name) = doc_property_arg(instr)
&& let Some(v) = doc_property(&s.doc, &name)
&& let Some(v) = doc_property(&s.doc, &core, &name)
{
updates.push((k, v));
updates.push((k, text_result(instr, v)));
}
}
"FILENAME" => {
let (fn_name, fn_path) = s.resolved_filenames();
let text = if has_switch(instr, 'p') { fn_path } else { fn_name };
updates.push((k, text));
updates.push((k, text_result(instr, text)));
}
_ => {}
}
}
if core != s.doc.core {
s.doc.core.clone_from(&core);
}
if updates.is_empty() {
continue;
}
@@ -1076,7 +1132,7 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
// Word's default picture when the code carries no `\@` switch.
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, super::insert::format_date(&pic)));
updates.push((k, text_result(instr, super::insert::format_date(&pic))));
}
if updates.is_empty() {
continue;
@@ -1092,6 +1148,52 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
}
/// The summary property a TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field shows.
fn summary_property<'a>(core: &'a wordcraft_doc::CoreProps, field: &str) -> &'a str {
match field {
"TITLE" => &core.title,
"SUBJECT" => &core.subject,
"AUTHOR" => &core.creator,
"KEYWORDS" => &core.keywords,
"COMMENTS" => &core.description,
_ => "",
}
}
fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: String) {
match field {
"TITLE" => core.title = value,
"SUBJECT" => core.subject = value,
"AUTHOR" => core.creator = value,
"KEYWORDS" => core.keywords = value,
"COMMENTS" => core.description = value,
_ => {}
}
}
/// The new value a summary-property field gives its property: the argument after the field's
/// name (`SUBJECT "Quarterly report"`, or one word without quotes: `SUBJECT Draft`), at most 255
/// characters as Word allows.
fn new_property_value(instr: &str) -> Option<String> {
FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect())
}
/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format, digits without one or
/// when the format does not cover the count (see [`super::table_formula::number_format_covers`]),
/// with the field's capitalization switches.
fn count_text(instr: &str, n: usize) -> String {
let fmt = wordcraft_layout::fields::format_switch(instr)
.filter(|f| super::table_formula::number_format_covers(*f, n as f64))
.unwrap_or(NumFormat::Decimal);
wordcraft_layout::fields::field_number(instr, fmt, u32::try_from(n).unwrap_or(u32::MAX))
}
/// A field's computed text with its capitalization switches (`\* Upper`, `\* Lower`, `\* Caps`,
/// `\* FirstCap`) applied in order.
fn text_result(instr: &str, text: String) -> String {
wordcraft_layout::fields::apply_case_switch(instr, text)
}
/// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name,
/// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`).
fn doc_property_arg(instr: &str) -> Option<String> {
@@ -1105,12 +1207,12 @@ fn doc_property_arg(instr: &str) -> Option<String> {
/// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property
/// when one has this name, else the built-in property (English names, case-insensitively, as
/// Word's field accepts). `None` for a name this application doesn't know.
fn doc_property(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
/// Word's field accepts) from `core`. `None` for a name this application doesn't know.
fn doc_property(doc: &wordcraft_doc::Document, core: &wordcraft_doc::CoreProps, name: &str) -> Option<String> {
if let Some(v) = doc.custom_prop(name) {
return Some(v.to_string());
}
let c = &doc.core;
let c = core;
Some(match name.trim().to_ascii_lowercase().as_str() {
"title" => c.title.clone(),
"subject" => c.subject.clone(),
@@ -1444,6 +1546,158 @@ mod tests {
assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched");
}
/// A session whose body reads "one two three" and whose header holds stale fields with
/// these codes, the caret in the header.
fn header_fields_session(doc: wordcraft_doc::Document, instrs: &[&str]) -> (Session, u32) {
use wordcraft_doc::{CharProps, Paragraph, PartKind};
let mut p = Paragraph::new();
for instr in instrs {
let off = p.len();
let field =
InlineObject::Field { instr: (*instr).into(), result: "stale".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(off, field, &CharProps::default()).unwrap();
}
let mut doc = doc;
let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]);
let mut s = Session::new(doc);
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
(s, hdr)
}
/// SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY and REVNUM show the document properties, and
/// NUMCHARS counts the body's characters without spaces; the counts take a `\*` format.
#[test]
fn document_information_fields_show_the_properties() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Budget".into();
doc.core.keywords = "plan; costs".into();
doc.core.description = "Draft for review".into();
doc.core.last_modified_by = "Grace".into();
doc.core.revision = 7;
let codes = ["SUBJECT", "KEYWORDS", "COMMENTS", "LASTSAVEDBY", "REVNUM", r"REVNUM \* ROMAN", "NUMCHARS", r"NUMWORDS \* roman"];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]);
}
/// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property
/// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value.
#[test]
fn summary_fields_with_a_new_value_replace_the_property() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Old".into();
let long = "x".repeat(300);
let long_code = format!("COMMENTS \"{long}\"");
let codes = [
r#"SUBJECT "Quarterly report""#,
"SUBJECT",
r#"TITLE "Plan" \* MERGEFORMAT"#,
r#"AUTHOR "Ada""#,
r#"KEYWORDS "a, b""#,
&long_code,
"DOCPROPERTY Subject",
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got[..5], ["Quarterly report", "Quarterly report", "Plan", "Ada", "a, b"]);
assert_eq!(got[5].chars().count(), 255);
assert_eq!(got[6], "Quarterly report", "DOCPROPERTY reads the subject the paragraph set");
let c = &s.doc.core;
assert_eq!((c.subject.as_str(), c.title.as_str(), c.creator.as_str(), c.keywords.as_str()), ("Quarterly report", "Plan", "Ada", "a, b"));
assert_eq!(c.description, got[5]);
}
/// One word after the name sets the property without quotes; a switch alone sets nothing.
#[test]
fn summary_fields_take_one_word_without_quotes() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Old".into();
doc.core.creator = "Grace".into();
doc.core.title = "Plan".into();
let (mut s, hdr) = header_fields_session(doc, &["SUBJECT Draft", "AUTHOR Ada", r"TITLE \* Upper"]);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Draft", "Ada", "PLAN"]);
let c = &s.doc.core;
assert_eq!((c.subject.as_str(), c.creator.as_str(), c.title.as_str()), ("Draft", "Ada", "Plan"));
}
/// `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the text Update Field computes,
/// and `\* CardText` and `\* OrdText` are lowercase unless one of them follows.
#[test]
fn update_fields_apply_the_capitalization_switches() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "budget".into();
doc.core.creator = "Ada LOVELACE".into();
doc.core.title = "weekly report on sales".into();
doc.core.description = "weekly report on sales".into();
doc.core.last_modified_by = "grace".into();
doc.core.revision = 7;
let codes = [
r"SUBJECT \* Upper",
r"AUTHOR \* Lower",
r"TITLE \* Caps \* MERGEFORMAT",
r"COMMENTS \*FirstCap",
r"LASTSAVEDBY \* upper",
r"NUMWORDS \* CardText",
r"NUMWORDS \* CardText \* Caps",
r"REVNUM \* OrdText",
r"REVNUM \* OrdText \* FirstCap",
r"DOCPROPERTY Subject \* Upper",
r#"SUBJECT "new \* Upper""#,
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(
got,
[
"BUDGET",
"ada lovelace",
"Weekly Report On Sales",
"Weekly report on sales",
"GRACE",
"three",
"Three",
"seventh",
"Seventh",
"BUDGET",
"new \\* Upper"
]
);
}
/// The capitalization switches also apply to REF, PAGEREF, SEQ and FILENAME results in the body.
#[test]
fn body_fields_apply_the_capitalization_switches() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Bookmark target text here.\nSecond paragraph."));
s.run("select.text", &json!({"text": "Bookmark target text"})).unwrap();
s.run("insert.bookmark", &json!({"name": "TargetMark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for instr in [r"REF TargetMark \* Upper", r"PAGEREF TargetMark \* CardText", r"SEQ Figure \* OrdText \* Caps", r"FILENAME \* Lower"] {
s.run("insert.field", &json!({"instr": instr, "result": "old"})).unwrap();
}
update_fields(&mut s).unwrap();
let got: Vec<String> = fields(&s).into_iter().map(|(_, r)| r).collect();
let name = s.resolved_filenames().0.to_lowercase();
assert_eq!(got, ["BOOKMARK TARGET TEXT".to_string(), "one".into(), "First".into(), name]);
}
/// A count the field's number format does not cover keeps its digits.
#[test]
fn counts_past_the_number_format_keep_their_digits() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.revision = 1_000_000;
let codes = [r"REVNUM \* ROMAN", r"REVNUM \* CardText", r"REVNUM \* alphabetic", r"NUMWORDS \* ROMAN"];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["1000000", "1000000", "1000000", "III"]);
}
/// #1706: `all: true` (printing) updates the whole document even with the caret in a header.
#[test]
fn update_fields_all_covers_every_story() {
+224 -25
View File
@@ -36,10 +36,14 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"value"?: bool (omit to toggle)}"#)
.key("Mod+Shift+E"),
CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, _| resolve_all(s, true)),
CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, _| resolve_all(s, false)),
CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, _| resolve_sel(s, true)),
CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, _| resolve_sel(s, false)),
CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, v| resolve_all_then(s, v, true))
.params(r#"{"stopTracking"?: bool (also turn Track Changes off)}"#),
CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, v| resolve_all_then(s, v, false))
.params(r#"{"stopTracking"?: bool (also turn Track Changes off)}"#),
CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, v| resolve_sel_then(s, v, true))
.params(r#"{"next"?: bool (then select the next change)}"#),
CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, v| resolve_sel_then(s, v, false))
.params(r#"{"next"?: bool (then select the next change)}"#),
CommandSpec::new("review.nextChange", "Next Change", "Review › Changes", |s, _| nav_change(s, 1)).pure(),
CommandSpec::new("review.previousChange", "Previous Change", "Review › Changes", |s, _| nav_change(s, -1)).pure(),
CommandSpec::new("review.markup", "Display for Review", "Review › Tracking", |s, v| {
@@ -141,9 +145,20 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))])?;
let id = comment_id(s)?;
let initials = s.user_initials();
s.doc
.comments
.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: None, resolved: false, part, ..Default::default() });
let now = now_iso();
s.doc.comments.insert(
id,
Comment {
author: s.author.clone(),
initials,
date: now.clone(),
date_utc: Some(now),
parent: None,
resolved: false,
part,
..Default::default()
},
);
let props = Default::default();
s.doc.insert_object(&b, InlineObject::CommentEnd { id }, &props)?;
s.doc.insert_object(&a, InlineObject::CommentStart { id }, &props)?;
@@ -170,9 +185,19 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult {
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))])?;
let id = comment_id(s)?;
let initials = s.user_initials();
let now = now_iso();
s.doc.comments.insert(
id,
Comment { author: s.author.clone(), initials, date: now_iso(), parent: Some(parent), resolved: false, part, ..Default::default() },
Comment {
author: s.author.clone(),
initials,
date: now.clone(),
date_utc: Some(now),
parent: Some(parent),
resolved: false,
part,
..Default::default()
},
);
Ok(json!({"id": id}))
}
@@ -363,20 +388,8 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
sel_result(s)
}
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
/// the section break on it), `props` the paragraph's own formatting change.
#[allow(clippy::too_many_arguments)]
fn resolve_para(
s: &mut Session,
story: StoryRef,
path: &wordcraft_doc::Path,
from: usize,
to: usize,
mark: bool,
props: bool,
accept: bool,
) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
/// Accept or reject the tracked formatting and text of `from..to` in one paragraph (not its mark).
fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, props: bool, accept: bool) -> Result<(), CmdError> {
if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) {
fmt_revisions::resolve_para(&mut para.props, accept);
para.touch();
@@ -397,6 +410,24 @@ fn resolve_para(
})?;
}
}
Ok(())
}
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
/// the section break on it), `props` the paragraph's own formatting change.
#[allow(clippy::too_many_arguments)]
fn resolve_para(
s: &mut Session,
story: StoryRef,
path: &wordcraft_doc::Path,
from: usize,
to: usize,
mark: bool,
props: bool,
accept: bool,
) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
resolve_runs(para, from, to, props, accept)?;
if !mark {
return Ok(());
}
@@ -513,6 +544,28 @@ pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
out
}
/// Accept or reject the tracked text and paragraph marks of the top-level paragraphs in a block
/// list that is not a story (a note separator).
fn resolve_loose_blocks(blocks: &mut wordcraft_doc::Blocks, accept: bool) -> Result<(), CmdError> {
for b in blocks.iter_mut() {
let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) else { continue };
let len = p.len();
resolve_runs(p, 0, len, true, accept)?;
if p.mark.fmt_change.is_some() || p.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
fmt_revisions::resolve_char(&mut p.mark, accept);
if let Some(sec) = p.section.as_deref_mut() {
fmt_revisions::resolve_section(sec, accept);
}
}
// A mark can't be removed here (no neighbour to join), so it just stops being a revision.
p.mark.ins = None;
p.mark.del = None;
p.touch();
}
fmt_revisions::resolve_containers(blocks, accept, 0);
Ok(())
}
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for st in stories {
@@ -527,12 +580,33 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
fmt_revisions::resolve_containers(&mut part.blocks, accept, 0);
}
fmt_revisions::resolve_section(&mut s.doc.last_section, accept);
// The note separators are block lists of their own, not stories; they must not keep ids of
// revisions that are about to be dropped.
for sep in [&mut s.doc.footnote_separators, &mut s.doc.endnote_separators] {
for blocks in [&mut sep.separator, &mut sep.continuation_separator, &mut sep.continuation_notice].into_iter().flatten() {
resolve_loose_blocks(blocks, accept)?;
}
}
s.doc.revisions.clear();
s.clamp_selection();
sel_result(s)
}
fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
/// Accept or Reject All; with `stopTracking`, Track Changes is turned off as well.
fn resolve_all_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
let out = resolve_all(s, accept)?;
if p::bool(v, "stopTracking") == Some(true) {
s.doc.settings.track_changes = false;
}
Ok(out)
}
/// Accept or Reject at the selection; with `next`, the next change is selected afterwards.
fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
resolve_sel(s, accept, p::bool(v, "next") == Some(true))
}
fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
let (a, b) = s.sel.ordered();
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
let mut caret_mark = None;
@@ -594,7 +668,7 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
}
None => {
if resolve_cell(s, a.story, &a.path, accept)? {
return sel_result(s);
return if next { select_next_unresolved(s) } else { sel_result(s) };
}
return nav_change(s, 1);
}
@@ -619,7 +693,7 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
super::moves::resolve(s, &moves, accept)?;
s.sel = Selection::caret(a);
s.clamp_selection();
sel_result(s)
if next { select_next_unresolved(s) } else { sel_result(s) }
}
fn changes(s: &Session) -> Vec<Change> {
@@ -808,6 +882,18 @@ fn nav_change(s: &mut Session, dir: i32) -> CmdResult {
sel_result(s)
}
/// After a change at the caret is resolved: select the first change from the caret on, including
/// one that starts and ends there (an empty paragraph's, a paragraph mark's or a table's
/// formatting), else the first change in the document.
fn select_next_unresolved(s: &mut Session) -> CmdResult {
let list = changes(s);
let caret = s.sel.ordered().0;
if let Some(c) = list.iter().find(|c| c.a >= caret).or(list.first()) {
s.sel = Selection { anchor: c.a.clone(), focus: c.b.clone() };
}
sel_result(s)
}
fn list_changes(s: &mut Session, _: &Value) -> CmdResult {
let list = changes(s);
Ok(Value::Array(
@@ -1619,6 +1705,119 @@ mod tests {
}
}
/// "one two three" with "new " typed at the start, "two " deleted and " end" typed at the end,
/// under Track Changes: three changes.
fn three_changes() -> Session {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.author = "Ana".into();
s.run("document.setText", &json!({"text": "one two three"})).unwrap();
s.run("review.trackChanges", &json!({"value": true})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "new "})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 8}, "focus": {"block": 0, "off": 12}})).unwrap();
s.run("text.delete", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.insert", &json!({"text": " end"})).unwrap();
s
}
fn change_spans(s: &mut Session) -> Vec<(String, u64, u64)> {
let list = s.run("review.changes", &json!({})).unwrap();
list.as_array()
.unwrap()
.iter()
.map(|c| (c["kind"].as_str().unwrap().to_string(), c["start"]["off"].as_u64().unwrap(), c["end"]["off"].as_u64().unwrap()))
.collect()
}
fn selected(s: &Session) -> (usize, usize) {
let (a, b) = s.sel.ordered();
(a.off, b.off)
}
/// Accept and Move to Next, Reject and Move to Next: the change at the caret is resolved and
/// the next one is selected; without `next` the caret stays where the change was.
#[test]
fn accept_and_reject_can_move_to_the_next_change() {
for (cmd, accept) in [("review.accept", true), ("review.reject", false)] {
let mut s = three_changes();
let before = change_spans(&mut s);
assert_eq!(before.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["insert", "delete", "insert"], "{cmd}");
s.run("caret.docStart", &json!({})).unwrap();
s.run(cmd, &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.len(), 2, "{cmd}: the first insertion is resolved");
assert_eq!(after[0].0, "delete", "{cmd}");
let deletion = (after[0].1 as usize, after[0].2 as usize);
assert_eq!(selected(&s), deletion, "{cmd}: the deletion is selected next");
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).starts_with("new "), accept, "{cmd}");
// Without `next`, the caret stays at the resolved change.
let mut s = three_changes();
s.run("caret.docStart", &json!({})).unwrap();
s.run(cmd, &json!({})).unwrap();
assert_eq!(selected(&s), (0, 0), "{cmd}");
}
}
/// With the caret on no change, Accept and Move to Next selects the next change without
/// resolving it, as Accept and Next Change do.
#[test]
fn accept_and_move_to_next_off_a_change_selects_it_unresolved() {
let mut s = three_changes();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 5}})).unwrap();
s.run("review.accept", &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.len(), 3, "nothing is resolved");
assert_eq!(selected(&s), (after[1].1 as usize, after[1].2 as usize), "the deletion after the caret is selected");
}
/// Reject and Move to Next on the only text of a centered paragraph: the paragraph's formatting
/// change, now empty at the caret, is selected next, not the insertion after it.
#[test]
fn move_to_next_selects_a_change_left_at_the_caret() {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.author = "Ana".into();
s.run("document.setText", &json!({"text": "one\n\nthree"})).unwrap();
s.run("review.trackChanges", &json!({"value": true})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}})).unwrap();
s.run("para.align", &json!({"value": "center"})).unwrap();
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "!"})).unwrap();
let before = change_spans(&mut s);
assert_eq!(before.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["format", "insert", "insert"], "{before:?}");
s.run("select.range", &json!({"anchor": {"block": 1, "off": 1}, "focus": {"block": 1, "off": 1}})).unwrap();
s.run("review.reject", &json!({"next": true})).unwrap();
let after = change_spans(&mut s);
assert_eq!(after.iter().map(|c| c.0.as_str()).collect::<Vec<_>>(), ["format", "insert"], "{after:?}");
let (a, b) = s.sel.ordered();
let at = (wordcraft_doc::Path::top(1), 0);
assert_eq!(((a.path.clone(), a.off), (b.path.clone(), b.off)), (at.clone(), at), "the centering is selected");
}
/// Accept All Changes and Stop Tracking, Reject All Changes and Stop Tracking: every change is
/// resolved and Track Changes is off; plain Accept All and Reject All leave it on.
#[test]
fn accept_and_reject_all_can_stop_tracking() {
for cmd in ["review.acceptAll", "review.rejectAll"] {
let mut s = three_changes();
s.run(cmd, &json!({"stopTracking": true})).unwrap();
assert!(change_spans(&mut s).is_empty(), "{cmd}");
assert!(!s.doc.settings.track_changes, "{cmd}: Track Changes is off");
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.settings.track_changes, "{cmd}: one undo step brings tracking and the changes back");
assert_eq!(change_spans(&mut s).len(), 3, "{cmd}");
let mut s = three_changes();
s.run(cmd, &json!({})).unwrap();
assert!(change_spans(&mut s).is_empty(), "{cmd}");
assert!(s.doc.settings.track_changes, "{cmd}: Track Changes stays on");
}
}
/// "plain words here" with "words" made bold, then 14 pt, and the paragraph centred, under
/// Track Changes by Ana.
fn formatted_while_tracking() -> Session {
+152 -24
View File
@@ -4,7 +4,7 @@ use super::table_formula::parse_number;
use serde_json::{Value, json};
use wordcraft_doc::collate::{SortKey, sort_key};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -171,7 +171,8 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| {
let (tp, _, _) = cell(s)?;
@@ -243,14 +244,15 @@ fn autofit(s: &mut Session, v: &Value) -> CmdResult {
pub(crate) fn sync_cell_widths(t: &mut Table) {
let grid = t.grid.clone();
for row in &mut t.rows {
let mut g = 0usize;
// A row's cells start after the grid columns it skips (`w:gridBefore`).
let mut g = row.first_grid();
for c in &mut row.cells {
let span = c.span();
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
if w > 0.0 {
c.props.width = Some(w);
}
g += span;
g = g.saturating_add(span);
}
}
}
@@ -516,6 +518,9 @@ pub(super) fn col(s: &mut Session, right: bool) -> CmdResult {
let k = before / total;
t.grid.iter_mut().for_each(|w| *w *= k);
}
// The inserted cells carry the pre-scale column width, and the layout prefers a cell width
// over the grid: sync them, or the shrink never reaches the layout or the saved `w:tcW`.
sync_cell_widths(t);
fix_caret(s, &tp, r, if right { c + 1 } else { c })
}
@@ -840,11 +845,7 @@ fn split_table(s: &mut Session, _: &Value) -> CmdResult {
}
let story = s.sel.focus.story;
let t = s.doc.table_mut(story, &tp)?;
let tail_rows = t.rows.split_off(r);
// Content controls closing after the table now close after its second part.
let close = std::mem::take(&mut t.controls.close);
let controls = wordcraft_doc::ControlWrap { open: Vec::new(), close };
let tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows, controls };
let tail = t.split_off(r);
let after = tp.with_last(tp.last() + 1);
s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?;
s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?;
@@ -944,26 +945,153 @@ fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
})
}
/// Table Layout › Data › Convert to Text: the selected rows, or the whole table with the caret
/// in one cell, become paragraphs. Each row is one paragraph with its cells set apart by the
/// separator (a tab, a comma or another character); with `paragraph` each cell paragraph
/// stays a separate paragraph. The text keeps its formatting and objects, a table nested in a cell stays
/// a table, and rows above and below the selected ones stay a table each.
fn to_text(s: &mut Session, v: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
let sep = match p::str(v, "separator").unwrap_or("tab") {
"comma" => ",",
"paragraph" => "\n",
_ => "\t",
let sep = match p::str(v, "separator") {
None | Some("tab") => Some('\t'),
Some("comma") => Some(','),
Some("paragraph") => None,
Some("other") => match p::str(v, "char").and_then(|c| c.chars().next()) {
// U+FFFC marks an object in the text, so it cannot set the cells apart.
Some(c) if c != '\n' && c != '\r' && c != wordcraft_doc::para::OBJ => Some(c),
_ => return Err(CmdError::Params("`char` (the separating character) is required with `other`".into())),
},
Some(x) => return Err(CmdError::Params(format!("unknown separator `{x}`"))),
};
let story = s.sel.focus.story;
let t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
let mut t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
// Several cells selected: their rows. A caret (or a selection inside one cell): every row.
let cells = selected_cells(s);
let (r0, r1) = match (cells.iter().map(|x| x.0).min(), cells.iter().map(|x| x.0).max()) {
(Some(a), Some(b)) if cells.len() > 1 || s.cell_range().is_some() => (a, b.min(t.rows.len().saturating_sub(1))),
_ => (0, t.rows.len().saturating_sub(1)),
};
// `split_off` ends the vertical merges and row content controls that a cut crosses, and opens
// them again (without their ids) below it.
let mut tail = t.split_off(r1 + 1);
let rows = t.split_off(r0).rows;
t.controls.close = std::mem::take(&mut tail.controls.close);
let mut blocks = Vec::new();
if !t.rows.is_empty() {
let mut head = Table { props: t.props.clone(), grid: t.grid.clone(), rows: std::mem::take(&mut t.rows), controls: Default::default() };
head.controls.open = std::mem::take(&mut t.controls.open);
blocks.push(Block::Table(head));
}
let text_from = blocks.len();
for row in rows {
row_to_text(row, sep, &mut blocks);
}
// Rows without cells (only from a damaged file) still leave a paragraph in their place.
if blocks.len() == text_from {
blocks.push(Block::Para(Paragraph::new()));
}
// The caret goes to the first converted paragraph (into a nested table when there is none).
let caret = match blocks.iter().enumerate().skip(text_from).find(|(_, b)| matches!(b, Block::Para(_))) {
Some((k, _)) => tp.with_last(tp.last() + k),
None => Path(tp.with_last(tp.last() + text_from).0.into_iter().chain([0, 0, 0]).collect()),
};
if !tail.rows.is_empty() {
tail.controls.close = std::mem::take(&mut t.controls.close);
// A floating table keeps its position in the part above; the part below goes in the text
// flow.
if text_from > 0 {
tail.props.float = None;
}
blocks.push(Block::Table(tail));
}
// A content control around the table keeps both ends: on the text where no table part is
// left to carry them.
let (open, close) = (std::mem::take(&mut t.controls.open), std::mem::take(&mut t.controls.close));
match blocks.get_mut(text_from) {
Some(Block::Para(p)) => {
for (k, mut control) in open.into_iter().enumerate() {
control.block = true;
let start = InlineObject::ControlStart { control: Box::new(control) };
p.insert_object(k * wordcraft_doc::para::OBJ.len_utf8(), start, &Default::default())?;
}
}
Some(Block::Table(x)) => {
let inner = std::mem::replace(&mut x.controls.open, open);
x.controls.open.extend(inner);
}
None => {}
}
match blocks.last_mut() {
Some(Block::Para(p)) => {
// The count is read from the file; at most 1,024 ends go onto the text.
for _ in 0..close.min(1024) {
let end = p.len();
p.insert_object(end, InlineObject::ControlEnd, &Default::default())?;
}
}
Some(Block::Table(x)) => x.controls.close = x.controls.close.saturating_add(close),
None => {}
}
s.doc.remove_block(story, &tp)?;
let mut i = tp.last();
for row in &t.rows {
let text: Vec<String> = row.cells.iter().map(cell_text).collect();
for line in text.join(sep).split('\n') {
s.doc.insert_block(story, &tp.with_last(i), Block::Para(Paragraph::with_text(line, Default::default())))?;
i += 1;
for (k, b) in blocks.into_iter().enumerate() {
s.doc.insert_block(story, &tp.with_last(tp.last() + k), b)?;
}
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: caret, off: 0 }));
sel_result(s)
}
/// One row of Convert to Text as blocks: a paragraph per row (and per further paragraph in a
/// cell) with the cells set apart by `sep`, or each cell's blocks as they are without one.
fn row_to_text(row: wordcraft_doc::Row, sep: Option<char>, out: &mut Vec<Block>) {
let Some(sep) = sep else {
for b in row.cells.into_iter().flat_map(|c| c.blocks) {
out.push(match std::sync::Arc::unwrap_or_clone(b) {
Block::Para(mut para) => {
para.section = None;
para.touch();
Block::Para(para)
}
other => other,
});
}
return;
};
let mut line: Option<Paragraph> = None;
for (ci, cell) in row.cells.into_iter().enumerate() {
if ci > 0 {
// The separator takes no formatting from the text before it.
line.get_or_insert_with(Paragraph::new).append(Paragraph::with_text(sep.encode_utf8(&mut [0; 4]), Default::default()));
}
let mut first = true;
for b in cell.blocks {
match std::sync::Arc::unwrap_or_clone(b) {
Block::Para(mut para) => {
para.section = None;
match line.as_mut() {
Some(p) if first => p.append(para),
_ => {
if let Some(done) = line.take() {
out.push(Block::Para(done));
}
para.touch();
line = Some(para);
}
}
}
other => {
// A nested table stays a table between the row's paragraphs.
if let Some(done) = line.take() {
out.push(Block::Para(done));
}
out.push(other);
}
}
first = false;
}
}
s.sel = Selection::caret(Pos { story, path: tp, off: 0 });
sel_result(s)
if let Some(done) = line {
out.push(Block::Para(done));
}
}
/// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again
@@ -994,8 +1122,8 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"result": text, "formula": instr}))
}
/// A formula as a field stores it: upper case, with one leading `=`. A `\#` numeric picture is
/// kept as typed.
/// A formula as a field stores it: upper case, with one leading `=`. The `\#` numeric picture and
/// the `\*` switches are kept as typed.
pub(crate) fn formula_instr(raw: &str) -> String {
let body = raw.trim();
let body = body.strip_prefix('=').unwrap_or(body);
+90 -7
View File
@@ -59,7 +59,8 @@ pub fn format_result(v: f64) -> String {
}
/// The text a formula field shows: its result under its `\#` numeric picture when it has one (see
/// [`format_picture`]), otherwise as [`format_result`] shows it.
/// [`format_picture`]), otherwise as [`format_result`] shows it, and then under its `\*` switches:
/// a number format such as `\* CardText`, then a capitalization switch such as `\* Upper`.
pub fn evaluate_field(t: &Table, r: usize, c: usize, instr: &str) -> Result<String, CmdError> {
evaluate_field_with(t, None, None, r, c, instr)
}
@@ -74,7 +75,7 @@ pub fn evaluate_field_with(
) -> Result<String, CmdError> {
let (formula, switch) = split_switch(instr);
let value = evaluate_with(t, doc, default_lang, r, c, formula)?;
Ok(match switch.and_then(picture_of) {
let text = match switch.and_then(picture_of) {
Some(picture) => format_picture(value, &picture),
None => {
let out_lang = doc
@@ -97,21 +98,63 @@ pub fn evaluate_field_with(
.or_else(|| default_lang.map(str::to_string));
wordcraft_doc::locale_number::format_number(value, out_lang.as_deref())
}
};
Ok(match switch {
Some(switch) => format_switches(switch, value, text),
None => text,
})
}
/// Splits a field's `\#` switch off its formula: `=SUM(ABOVE) \# "0.00"` gives the formula and the
/// switch text (from the `\#` on).
/// The text of a formula result under the field's `\*` switches, where `text` is `value` formatted
/// with the `\#` picture, or in the cell's language when there is no picture. A number format such
/// as `\* CardText`, `\* OrdText` or `\* roman` writes a whole number the format covers (up to
/// 999,999 for CardText and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic), in lowercase
/// for CardText and OrdText. `\* Hex` writes a whole number from 0 to 4,294,967,295 in uppercase
/// hexadecimal digits. `\* DollarText` writes a result from 0 to 999,999.99 in lowercase cardinal
/// text with its first two decimals (rounded) over 100 (see
/// [`wordcraft_layout::fields::dollar_text`]). Any other result keeps its digits. The capitalization
/// switches (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`) then apply to the text in order. Other
/// `\*` switches leave the text as it is. Quoted text, such as a `\#` picture, is not a switch.
fn format_switches(switches: &str, value: f64, text: String) -> String {
use wordcraft_doc::section::NumFormat;
let unquoted = switches.split('"').step_by(2).collect::<Vec<_>>().join(" ");
let fmt = wordcraft_layout::fields::format_switch(&unquoted).filter(|f| *f != NumFormat::Decimal);
match fmt {
Some(NumFormat::DollarText) => {
let text = wordcraft_layout::fields::dollar_text(value).unwrap_or(text);
wordcraft_layout::fields::apply_case_switch(switches, text)
}
Some(fmt) if number_format_covers(fmt, value) => wordcraft_layout::fields::field_number(switches, fmt, value as u32),
_ => wordcraft_layout::fields::apply_case_switch(switches, text),
}
}
/// Whether the `\*` number format `fmt` writes `value`: a whole number up to 999,999 for CardText
/// and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic, and 0 to `u32::MAX` otherwise.
pub(crate) fn number_format_covers(fmt: wordcraft_doc::section::NumFormat, value: f64) -> bool {
use wordcraft_doc::section::NumFormat;
let range = match fmt {
NumFormat::CardinalText | NumFormat::OrdinalText => 0.0..=999_999.0,
NumFormat::LowerRoman | NumFormat::UpperRoman => 1.0..=50_000.0,
NumFormat::LowerLetter | NumFormat::UpperLetter => 1.0..=780.0,
_ => 0.0..=f64::from(u32::MAX),
};
value.fract() == 0.0 && range.contains(&value)
}
/// Splits a field's switches (`\#` and `\*`) off its formula: `=SUM(ABOVE) \# "0.00"` gives the
/// formula and the switch text (from the first switch on).
pub fn split_switch(instr: &str) -> (&str, Option<&str>) {
match instr.find("\\#") {
match [instr.find("\\#"), instr.find("\\*")].into_iter().flatten().min() {
Some(at) => (instr[..at].trim_end(), Some(&instr[at..])),
None => (instr, None),
}
}
/// The picture a `\#` switch names: a quoted one (`"#,##0.00"`) or a single word.
/// The picture of the `\#` switch in a field's switch text: a quoted one (`"#,##0.00"`) or a
/// single word.
fn picture_of(switch: &str) -> Option<String> {
let rest = switch.strip_prefix("\\#")?.trim_start();
let rest = switch[switch.find("\\#")? + 2..].trim_start();
let picture = match rest.strip_prefix('"') {
Some(quoted) => quoted.split('"').next().unwrap_or_default(),
None => rest.split_whitespace().next().unwrap_or_default(),
@@ -954,6 +997,46 @@ mod tests {
assert_eq!(shown("=A1 \\# \"\""), "1234.50", "an empty picture is none");
}
#[test]
fn a_format_switch_writes_the_result_as_text_and_sets_its_capitals() {
let t = table(&[&["790"], &["1.5"], &[""]]);
assert_eq!(split_switch("=A1 \\* Caps \\# 0"), ("=A1", Some("\\* Caps \\# 0")));
let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}"));
assert_eq!(shown("=A1 \\* CardText"), "seven hundred ninety");
assert_eq!(shown("=A1 \\* CardText \\* Caps"), "Seven Hundred Ninety");
assert_eq!(shown("=A1 \\* OrdText \\* FirstCap"), "Seven hundred ninetieth");
assert_eq!(shown("=A1 \\* roman"), "dccxc");
assert_eq!(shown("=A1 \\* MERGEFORMAT"), "790", "a switch that formats nothing");
assert_eq!(shown("=A1 \\# \"#,##0.00\" \\* MERGEFORMAT"), "790.00");
assert_eq!(shown("=A1 \\* MERGEFORMAT \\# 0.0"), "790.0", "a picture after a format switch");
assert_eq!(shown("=A1 \\# \"0 \\* CardText items\""), "790 \\* CardText items", "a switch inside the picture is text");
assert_eq!(shown("=A2 \\* CardText"), "1.50", "a fraction keeps its digits");
assert_eq!(shown("=A1-800 \\* CardText"), "-10", "a negative number keeps its digits");
assert_eq!(shown("=A1*1266 \\* CardText"), "1000140", "a number past CardText keeps its digits");
assert_eq!(shown("=A1*1266 \\* OrdText \\* Upper"), "1000140");
assert_eq!(shown("=A1*64 \\* roman"), "50560", "a number past roman keeps its digits");
assert_eq!(shown("=A1-790 \\* roman"), "0", "roman has no zero");
assert_eq!(shown("=A1 \\* alphabetic"), "790", "a number past alphabetic keeps its digits");
}
#[test]
fn dollar_text_and_hex_switches_write_the_result() {
let t = table(&[&["9.20", "5.35"], &["458", "-1"], &["", ""]]);
let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}"));
assert_eq!(shown("= 9.20 + 5.35 \\* DollarText \\* Upper"), "FOURTEEN AND 55/100");
assert_eq!(shown("=A1+B1 \\* DollarText"), "fourteen and 55/100");
assert_eq!(shown("=A2 \\* DollarText \\* FirstCap"), "Four hundred fifty-eight and 00/100");
assert_eq!(shown("=0.999 \\* DollarText"), "one and 00/100");
assert_eq!(shown("=A2 \\* Hex"), "1CA");
assert_eq!(shown("=A2 \\* Hex \\* Lower"), "1ca");
assert_eq!(shown("=A2*0 \\* Hex"), "0");
assert_eq!(shown("=B2 \\* DollarText"), shown("=B2"), "a negative number keeps its digits");
assert_eq!(shown("=B2 \\* Hex"), shown("=B2"));
assert_eq!(shown("=A2/4 \\* Hex"), shown("=A2/4"), "a fraction keeps its digits");
assert_eq!(shown("=A2*2200 \\* DollarText"), shown("=A2*2200"), "a number past DollarText keeps its digits");
assert_eq!(shown("=A2 \\* ArabicDash"), "- 458 -");
}
#[test]
fn a_huge_range_over_a_small_table_reads_only_the_table() {
let t = table(&[&["1", "2", "3"], &["4", "5", "6"], &["", "", ""]]);
+196 -19
View File
@@ -231,9 +231,12 @@ enum EnterAutoFormat {
Border(Border),
/// "+---+-----+": a one-row table; the dash count of each column.
Table(Vec<usize>),
/// A short line before an empty paragraph: Heading 1, 2 or 3.
Heading(u8),
}
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###".
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". A line
/// holding a tracked deletion stays as it is.
fn border_line(s: &Session, at: &Pos) -> Option<Border> {
let para = s.doc.para_at(at)?;
let text = para.text.as_str();
@@ -243,7 +246,8 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|| at.off != text.len()
|| text.len() < 3
|| !text.chars().all(|x| x == c)
|| para.props.numbering.is_some_and(|n| n.num != 0)
|| s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0)
|| para.run_ranges().any(|(_, r)| r.del.is_some())
{
return None;
}
@@ -260,8 +264,8 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
}
/// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold
/// leave the text alone.
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold,
/// or a line holding a tracked deletion, leave the text alone.
fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
let para = s.doc.para_at(at)?;
let text = para.text.as_str();
@@ -269,7 +273,8 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|| at.story != StoryRef::Body
|| at.path.0.len() != 1
|| at.off != text.len()
|| para.props.numbering.is_some_and(|n| n.num != 0)
|| s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0)
|| para.run_ranges().any(|(_, c)| c.del.is_some())
{
return None;
}
@@ -284,8 +289,76 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
Some(cols)
}
/// AutoFormat Built-in Heading styles: Enter in an empty body paragraph whose previous paragraph
/// is a line of Normal text with one to five words that ends in a letter or digit and holds no
/// tracked deletion. The line becomes Heading 1, or Heading 2 or 3 when it starts with one or
/// two tabs.
fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
let ac = &s.prefs.autocorrect;
let cur = s.doc.para_at(at)?;
let i = at.path.last();
if !ac.enabled || !ac.heading_styles || at.story != StoryRef::Body || at.path.0.len() != 1 || i == 0 || !cur.is_empty() {
return None;
}
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
let plain = |p: &wordcraft_doc::Paragraph| {
let r = s.doc.styles.resolve_para(&p.props);
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
};
let text = prev.text.trim_start_matches('\t');
let tabs = prev.text.len() - text.len();
if !plain(cur)
|| !plain(prev)
|| !prev.objects.is_empty()
|| prev.section.is_some()
|| prev.run_ranges().any(|(_, c)| c.del.is_some())
|| tabs > 2
|| !(1..=5).contains(&text.split_whitespace().count())
|| text.starts_with(char::is_whitespace)
|| text.contains(char::is_control)
|| !text.ends_with(char::is_alphanumeric)
{
return None;
}
let level = tabs as u8 + 1;
// Formatting limited to the document's styles: a locked heading style isn't applied.
let id = format!("Heading{level}");
if super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting && s.doc.styles.get(&id).is_some_and(|st| st.locked) {
return None;
}
Some(level)
}
/// Give the paragraph before the empty one at `at` Heading `level` without its leading tabs;
/// the caret stays in the empty paragraph. With Track Changes on, the tabs are marked deleted
/// (or removed, when this author typed them) and the style change is a formatting revision.
fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result<(), CmdError> {
let prev = at.path.with_last(at.path.last().saturating_sub(1));
let Some(before) = s.doc.para(at.story, &prev).cloned() else { return Ok(()) };
let tabs = before.text.len() - before.text.trim_start_matches('\t').len();
let start = Pos { story: at.story, path: prev.clone(), off: 0 };
s.sel = Selection { anchor: start.clone(), focus: Pos { off: tabs, ..start } };
delete_selection(s)?;
super::para::set_heading(s, at.story, &prev, Some(level))?;
// Removed tabs change the text, and the formatting comparison after the command only looks
// at paragraphs that keep theirs, so the style change is recorded here.
if s.doc.settings.track_changes
&& s.prefs.markup.track_formatting
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
{
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
let p = s.doc.para_mut(at.story, &prev)?;
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
p.touch();
}
s.sel = Selection::caret(at.clone());
Ok(())
}
/// Turn the border-line characters into a bottom border on the paragraph above (or this one
/// at the top of a story).
/// at the top of a story). With Track Changes on, the characters are marked deleted (or
/// removed, when this author typed them) and the border is a formatting revision.
fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> {
let set_bottom = |p: &mut wordcraft_doc::Paragraph| {
let mut b = p.props.borders.unwrap_or_default();
@@ -295,31 +368,52 @@ fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdE
};
let i = at.path.last();
let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None };
let cur = s.doc.para_mut(at.story, &at.path)?;
let len = cur.len();
cur.delete(0, len)?;
let start = Pos { off: 0, ..at.clone() };
let end = delete_typed_line(s, at)?;
match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) {
Some(q) => {
// The characters' paragraph becomes the one typing continues in.
set_bottom(s.doc.para_mut(at.story, &q)?);
s.sel = Selection::caret(start);
s.sel = Selection::caret(end);
}
None => {
let before = s.doc.para(at.story, &at.path).map(|p| p.props.clone());
set_bottom(s.doc.para_mut(at.story, &at.path)?);
let new = split_para(s, &start)?;
let new = split_para(s, &end)?;
let p = s.doc.para_mut(new.story, &new.path)?;
p.props.borders = None;
p.touch();
// The split changes the block count, and the formatting comparison after the command
// skips a container whose block count changed, so the border is recorded here.
if s.doc.settings.track_changes
&& s.prefs.markup.track_formatting
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
{
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
let p = s.doc.para_mut(at.story, &at.path)?;
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
p.touch();
}
s.sel = Selection::caret(new);
}
}
Ok(())
}
/// Delete the AutoFormat characters of the paragraph at `at`, tracked when Track Changes is on.
/// Returns the end of what is left: the paragraph's end, after any characters marked deleted.
fn delete_typed_line(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
let len = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
s.sel = Selection { anchor: Pos { off: 0, ..at.clone() }, focus: Pos { off: len, ..at.clone() } };
delete_selection(s)?;
let left = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
Ok(Pos { off: left, ..at.clone() })
}
/// Replace the "+---+" characters with a one-row table across the text width, each column as
/// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret
/// goes into the first cell.
/// goes into the first cell. With Track Changes on, the characters are marked deleted (or
/// removed, when this author typed them); the table is inserted untracked, as Insert › Table
/// inserts one.
fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> {
let total = cols.iter().sum::<usize>().max(1) as f32;
let block = at.path.0.first().copied().unwrap_or(0) as usize;
@@ -334,9 +428,7 @@ fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), Cmd
c.props.width = Some(w);
}
}
let p = s.doc.para_mut(at.story, &at.path)?;
let len = p.len();
p.delete(0, len)?;
delete_typed_line(s, at)?;
s.doc.insert_block(at.story, &at.path, Block::Table(t))?;
let mut first = at.path.0.clone();
first.extend([0, 0, 0]);
@@ -584,7 +676,10 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
return sel_result(s);
}
let mut at = delete_selection(s)?;
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
let auto = border_line(s, &at)
.map(EnterAutoFormat::Border)
.or_else(|| table_line(s, &at).map(EnterAutoFormat::Table))
.or_else(|| heading_line(s, &at).map(EnterAutoFormat::Heading));
if let Some(auto) = auto {
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
// and the Enter stays. Record that plain Enter as its own undo step.
@@ -596,6 +691,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
match auto {
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
}
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
@@ -603,6 +699,31 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
}
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
if s.sel.is_collapsed() && at.off > 0 {
// A `*bold*` or `_italic_` word: like the border lines above, Undo right after brings
// the marks back and the Enter stays.
let plain = super::tools::has_emphasis(s, true).then(|| (s.doc.clone(), s.sel.clone()));
if let Some((doc, sel)) = plain {
// Removes the marks, then AutoCorrects the word as without them.
super::tools::autocorrect_word(s, true)?;
s.autocap.take_if(|c| c.depth == u64::MAX);
let (done_doc, done_sel) = (std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel));
let new = split_para(s, &at)?;
let c = s.doc.para_at(&new).map(|p| p.mark.clone()).unwrap_or_default();
s.sel = Selection::caret(new);
let plain_doc = std::mem::replace(&mut s.doc, done_doc);
let plain_sel = std::mem::replace(&mut s.sel, done_sel);
let new = split_para(s, &s.sel.focus.clone())?;
let p = s.doc.para_mut(new.story, &new.path)?;
// The new paragraph is formatted as after a plain Enter (the closing mark's
// formatting, or none when the next style starts afresh); its mark keeps its revisions.
let (ins, del, fmt_change) = (p.mark.ins, p.mark.del, p.mark.fmt_change.take());
p.mark = wordcraft_doc::props::CharProps { ins, del, fmt_change, ..c };
p.touch();
s.sel = Selection::caret(new);
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
return sel_result(s);
}
super::tools::autocorrect_word(s, true)?;
s.autocap.take_if(|c| c.depth == u64::MAX);
at = s.sel.focus.clone();
@@ -613,7 +734,9 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
sel_result(s)
}
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
/// left- and first-indent with tabs and backspaces" on, at the start of a line of a paragraph with
/// text: indent. Else a tab.
fn tab(s: &mut Session, _: &Value) -> CmdResult {
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
let story = s.sel.focus.story;
@@ -643,10 +766,13 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
&& n.num != 0
{
let para = s.doc.para_mut(f.story, &f.path)?;
para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) });
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
para.touch();
return sel_result(s);
}
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
return indent_by_tab(s, &f);
}
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
// Multiple paragraphs selected: increase indent.
return super::para::indent(s, &Value::Null);
@@ -654,6 +780,52 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
ins_char(s, '\t')
}
/// Is AutoFormat As You Type's "Set left- and first-indent with tabs and backspaces" on? Not
/// while the document limits formatting to its styles: the indents are direct formatting.
fn tab_indent_on(s: &Session) -> bool {
let ac = &s.prefs.autocorrect;
ac.enabled && ac.tab_indent && !(super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting)
}
/// Does Tab at `f` indent instead of typing a tab? Only at the start of a line of a paragraph
/// with text. Not before the first line of a list item numbered by its style, where Tab types a
/// tab character as with the option off.
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
if f.off == 0 {
return !s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0);
}
let l = s.layout();
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
}
/// Tab before the first line indents the first line by one default tab stop; before any other
/// line, or when the first line is indented or hanging already, it indents the whole paragraph.
/// Before a later line of a list item, numbered directly or by its style, it takes the item down
/// a level.
fn indent_by_tab(s: &mut Session, f: &Pos) -> CmdResult {
let Some(para) = s.doc.para_at(f) else { return sel_result(s) };
let r = s.doc.styles.resolve_para(&para.props);
if f.off > 0
&& para.props.numbering.is_none()
&& let Some(n) = r.numbering.filter(|n| n.num != 0)
{
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
p.touch();
return sel_result(s);
}
if f.off == 0 && r.indent_first == 0.0 && r.indent_chars[2] == 0.0 {
let tab = s.doc.settings.default_tab.max(1.0);
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.indent_first = Some(tab);
p.props.cancel_char_indents(false, false, true);
p.touch();
return sel_result(s);
}
super::para::indent(s, &Value::Null)
}
fn back_tab(s: &mut Session, _: &Value) -> CmdResult {
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
let story = s.sel.focus.story;
@@ -733,6 +905,11 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
p.touch();
return sel_result(s);
}
if tab_indent_on(s) && (para.props.indent_left.is_some_and(|x| x > 0.0) || para.props.indent_left_chars.is_some_and(|x| x > 0.0)) {
// Set left- and first-indent with tabs and backspaces: the paragraph's own left indent
// (in points or characters) goes back one tab stop at a time before the paragraphs join.
return super::para::outdent(s, &Value::Null);
}
let i = f.path.last();
if i == 0 {
return sel_result(s); // start of a story or cell
+139 -11
View File
@@ -93,6 +93,9 @@ pub struct AutoCorrectPrefs {
pub fractions: bool,
/// Ordinals (1st) with superscript.
pub ordinals: bool,
/// *Bold* and _italic_ with real formatting: the marks are removed and the text between them
/// is made bold or italic. Off unless the user turns it on.
pub bold_italic: bool,
/// Hyphens (--) with dashes.
pub dashes: bool,
/// Internet addresses with hyperlinks.
@@ -103,6 +106,14 @@ pub struct AutoCorrectPrefs {
pub numbering: bool,
/// Border lines.
pub border_lines: bool,
/// Set left- and first-indent with tabs and backspaces: Tab at the start of a line of a
/// paragraph with text indents it instead of typing a tab, and Backspace at its start takes
/// the indent back. Off by default.
pub tab_indent: bool,
/// Built-in Heading styles: a second Enter after a line of five or fewer words that ends in a
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
/// Off by default.
pub heading_styles: bool,
/// The user's entries: added, or replacing a built-in one.
pub entries: Vec<(String, String)>,
/// Built-in entries the user deleted.
@@ -133,11 +144,14 @@ impl Default for AutoCorrectPrefs {
smart_quotes: true,
fractions: true,
ordinals: true,
bold_italic: false,
dashes: true,
links: true,
bullets: true,
numbering: true,
border_lines: true,
tab_indent: false,
heading_styles: false,
entries: Vec::new(),
removed: Vec::new(),
exceptions: Vec::new(),
@@ -322,11 +336,14 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
("smartQuotes", &mut ac.smart_quotes),
("fractions", &mut ac.fractions),
("ordinals", &mut ac.ordinals),
("boldItalic", &mut ac.bold_italic),
("dashes", &mut ac.dashes),
("links", &mut ac.links),
("bullets", &mut ac.bullets),
("numbering", &mut ac.numbering),
("borderLines", &mut ac.border_lines),
("tabIndent", &mut ac.tab_indent),
("headingStyles", &mut ac.heading_styles),
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
] {
@@ -395,11 +412,14 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
"smartQuotes": ac.smart_quotes,
"fractions": ac.fractions,
"ordinals": ac.ordinals,
"boldItalic": ac.bold_italic,
"dashes": ac.dashes,
"links": ac.links,
"bullets": ac.bullets,
"numbering": ac.numbering,
"borderLines": ac.border_lines,
"tabIndent": ac.tab_indent,
"headingStyles": ac.heading_styles,
"autoAddFirstLetter": ac.auto_add_first_letter,
"autoAddInitialCaps": ac.auto_add_initial_caps,
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
@@ -437,7 +457,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
.params(
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
)
.pure(),
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
@@ -466,18 +486,30 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
// Validate a restore before anything is saved: a rejected command must leave the
// version list as it was. A `restore` that is there but not a non-negative integer
// is refused, not read as absent.
let restore = if v.get("restore").is_some_and(|x| !x.is_null()) {
let i = p::u64(v, "restore").ok_or_else(|| CmdError::Params("restore (non-negative integer) is required".into()))?;
// A `u64` that doesn't fit in `usize` names no version: `as usize` would wrap it
// to a small index on a 32-bit target and restore the wrong document.
let i = usize::try_from(i).map_err(|_| CmdError::Params("no such version".into()))?;
let version = s.versions.get(i).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
// The restore replaces the whole document, including the content the protection
// kept read-only, so the protection is stopped first (with its password, when it
// has one) — a restore must not go around it.
super::protect::check_version_restore(s)?;
Some(version)
} else {
None
};
if let Some(label) = p::str(v, "save") {
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
if s.versions.len() > 50 {
s.versions.remove(0);
}
}
if let Some(i) = p::u64(v, "restore") {
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
// The restore replaces the whole document, including the content the protection
// kept read-only, so the protection is stopped first (with its password, when it
// has one) — a restore must not go around it.
super::protect::check_version_restore(s)?;
if let Some((_, _, d)) = restore {
s.checkpoint("Restore Version");
// A snapshot's own protection is stale: it was taken at some earlier moment, and
// restoring it must not bring a protection back that the live document doesn't
@@ -861,6 +893,8 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
if !s.prefs.autocorrect.enabled {
return Ok(());
}
// The word without its marks goes on through the steps below ("*teh* " → "The ").
let outside = emphasis_autoformat(s, on_enter)?;
let ac = s.prefs.autocorrect.clone();
let mut f = s.sel.focus.clone();
let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) };
@@ -871,11 +905,22 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
}
let trigger = if on_enter { 0 } else { last.len_utf8() };
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
// The word starts after the last white-space character, whatever its width (a no-break
// space is two bytes and an ideographic space is three).
let mut start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
if word.is_empty() {
return Ok(());
}
// The opening mark of a `*bold*` or `_italic_` span is not part of the word, so the first
// word of a span of several words is corrected as it is typed ("*teh end*" → "*the end*").
let mark = word.chars().next().filter(|c| ac.bold_italic && (*c == '*' || *c == '_'));
let mut opened = false;
if let Some(rest) = mark.and_then(|m| word.get(1..).filter(|r| !r.is_empty() && !r.contains(m))) {
start += 1;
word = rest.to_string();
opened = true;
}
// The word's language (run, paragraph style, document default) picks the replacement list
// (#835) and the case mapping (#1923). Text with no language at all is in the editing
// language (a Polish interface writes Polish), else English.
@@ -960,14 +1005,16 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
c.link = Some(href.clone());
c.style = Some("Hyperlink".into());
})?;
// Typing after the link isn't part of it (but keeps the text's other formatting).
let mut after = s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default();
// Typing after the link isn't part of it (but keeps the text's other formatting, or
// the formatting outside a `*bold*` or `_italic_` span).
let mut after = outside.unwrap_or_else(|| s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default());
after.link = None;
after.style = None;
s.pending = Some(after);
return Ok(());
}
let before_word = text.get(..start).unwrap_or("");
// A sentence starts before the opening mark, as for the word without it.
let before_word = text.get(..start - usize::from(opened)).unwrap_or("");
// The first word in a table cell follows its own option; other sentences the sentence one.
let sentence_on = if before_word.trim().is_empty() && f.path.cell().is_some() { ac.cap_cells } else { ac.cap_sentences };
let exceptions = ac.exception_list();
@@ -1097,6 +1144,87 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str, turkic: bool) -> Option<(usize,
None
}
/// *Bold* and _italic_ with real formatting, for the word ending at the caret (`on_enter` as for
/// [`autocorrect_word`]): the two marks are removed and the text between them is made bold or
/// italic, for plain and complex-script text. Returns the closing mark's formatting, which is
/// the formatting outside the span, when anything changed.
pub fn emphasis_autoformat(s: &mut Session, on_enter: bool) -> Result<Option<wordcraft_doc::props::CharProps>, CmdError> {
let Some((open, close, bold)) = emphasis_at_caret(s, on_enter) else { return Ok(None) };
let f = s.sel.focus.clone();
let para = s.doc.para_mut(f.story, &f.path)?;
let outside = para.props_of_char(close).clone();
para.delete(close, close + 1)?;
para.delete(open, open + 1)?;
para.format(open, close - 1, &|c| {
if bold {
c.bold = Some(true);
c.bold_cs = Some(true);
} else {
c.italic = Some(true);
c.italic_cs = Some(true);
}
})?;
s.sel = Selection::caret(Pos { off: f.off.saturating_sub(2), ..f });
Ok(Some(outside))
}
/// Would [`emphasis_autoformat`] change the word ending at the caret?
pub fn has_emphasis(s: &Session, on_enter: bool) -> bool {
emphasis_at_caret(s, on_enter).is_some()
}
/// The `*bold*` or `_italic_` span ending at the caret (see [`emphasis_span`]) when the option is
/// on. With Track Changes on, only the current author's tracked insertions are converted, since
/// removing the marks from other text would not be tracked. Nothing is converted while the
/// document limits formatting to its styles.
fn emphasis_at_caret(s: &Session, on_enter: bool) -> Option<(usize, usize, bool)> {
// Formatting limited to the document's styles allows no direct bold or italic.
let styles_only = super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting;
if !(s.prefs.autocorrect.enabled && s.prefs.autocorrect.bold_italic) || styles_only {
return None;
}
let f = &s.sel.focus;
let para = s.doc.para_at(f)?;
let before = para.text.get(..f.off)?;
let trigger = match before.chars().next_back() {
_ if on_enter => 0,
Some(c) if c == ' ' || ",.;:!?".contains(c) => c.len_utf8(),
_ => return None,
};
let span = before.get(..before.len() - trigger).and_then(emphasis_span)?;
let tracked = |(open, close, _): &(usize, usize, bool)| {
!s.doc.settings.track_changes
|| para
.run_ranges()
.filter(|(r, _)| r.start <= *close && r.end > *open)
.all(|(_, p)| p.del.is_none() && p.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == s.author))
};
Some(span).filter(tracked)
}
/// The marks of a `*bold*` or `_italic_` span that `text` ends with: the byte offsets of the
/// opening and closing mark, and whether it is bold. The opening mark starts a word (it follows
/// the paragraph start, white space or an opening bracket or quote), the text between the marks
/// holds a letter or digit and neither starts nor ends with white space, so `2*3*`, `file_name_`
/// and `*?!*` stay as typed. Text holding an object (a picture, field or content control) is left
/// alone.
fn emphasis_span(text: &str) -> Option<(usize, usize, bool)> {
let mark = text.chars().next_back().filter(|c| *c == '*' || *c == '_')?;
let close = text.len() - 1;
let open = text.get(..close)?.rfind(mark)?;
let inner = text.get(open + 1..close)?;
let edge_ok = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace());
if !edge_ok(inner.chars().next())
|| !edge_ok(inner.chars().next_back())
|| !inner.chars().any(char::is_alphanumeric)
|| inner.contains(wordcraft_doc::para::OBJ)
{
return None;
}
let starts_word = text.get(..open)?.chars().next_back().is_none_or(|c| c.is_whitespace() || "([{\"'“‘".contains(c));
starts_word.then_some((open, close, mark == '*'))
}
/// Byte length of an ordinal suffix to superscript ("1st" → 2), if `word` is a correct ordinal.
fn ordinal_suffix(word: &str) -> Option<usize> {
let digits = word.find(|c: char| !c.is_ascii_digit())?;
+29
View File
@@ -37,6 +37,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing)
.params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#)
.pure(),
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -171,6 +174,7 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
s.view.read_column_width = None;
s.view.read_zoom = 1.0;
s.view.read_line_spacing = None;
s.view.read_page_color = ReadPageColor::None;
}
s.view.mode = m;
s.view.read_mode = read;
@@ -213,6 +217,31 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ReadPageColor {
/// White paper; the document's page color and page picture are not shown.
#[default]
None,
/// Sepia paper (Word's RGB 112, 66, 20 at 80% transparency over white); the text keeps its colors.
Sepia,
/// A dark page with light text, as View › Switch Modes draws it.
Inverse,
}
fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
let color = match v.get("value").and_then(Value::as_str) {
Some("none") => ReadPageColor::None,
Some("sepia") => ReadPageColor::Sepia,
Some("inverse") => ReadPageColor::Inverse,
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
};
s.view.read_page_color = color;
Ok(json!({"pageColor": color}))
}
/// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%.
pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option<Zoom> {
let fit = match v.fit.as_str() {
+6 -2
View File
@@ -39,7 +39,11 @@ pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String>
continue;
}
let page = |t: &str| -> Result<usize, String> {
let n: usize = t.parse().map_err(|_| format!("`{item}` isn't a page or a range like 2-4"))?;
let not_a_page = || format!("`{item}` isn't a page or a range like 2-4");
if !t.bytes().all(|b| b.is_ascii_digit()) {
return Err(not_a_page());
}
let n: usize = t.parse().map_err(|_| not_a_page())?;
if n == 0 || n > total {
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
}
@@ -194,7 +198,7 @@ mod tests {
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", &"1,".repeat(400)] {
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] {
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
}
assert!(parse_page_range("1", 0).is_err());
+147 -14
View File
@@ -178,6 +178,9 @@ pub struct ViewState {
pub read_zoom: f32,
#[serde(skip)]
pub read_line_spacing: Option<f32>,
/// Read Mode's Page Color: the paper the reading columns are drawn on.
#[serde(skip)]
pub read_page_color: crate::cmd::view::ReadPageColor,
pub dark_mode: bool,
pub show_markup: bool,
pub track_changes_pane: bool,
@@ -265,6 +268,7 @@ impl Default for ViewState {
read_column_width: None,
read_zoom: default_read_zoom(),
read_line_spacing: None,
read_page_color: Default::default(),
dark_mode: false,
show_markup: true,
track_changes_pane: false,
@@ -323,8 +327,8 @@ struct Undo {
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
/// open typing group, a pending [`Session::join_next_undo`] and the dirty flag. See
/// [`Session::edit_snapshot`].
/// open typing group, a pending [`Session::join_next_undo`] (and whether its group has a step)
/// and the dirty flag. See [`Session::edit_snapshot`].
pub struct EditSnapshot {
doc: Document,
sel: Selection,
@@ -337,6 +341,7 @@ pub struct EditSnapshot {
redo: Vec<Arc<Undo>>,
typing_open: bool,
join_next: bool,
join_anchor: bool,
dirty: bool,
}
@@ -366,6 +371,8 @@ pub struct Session {
pub view: ViewState,
/// Formatting picked with a collapsed caret, applied to the next typed text.
pub pending: Option<CharProps>,
/// Last highlighter choice for the shortcut; palette state, outside document undo.
pub(crate) last_highlight: wordcraft_doc::props::Highlight,
pub path: Option<std::path::PathBuf>,
/// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file
/// name, not document metadata, so it is not part of the document or its undo history.
@@ -416,6 +423,10 @@ pub struct Session {
typing_open: bool,
/// The next mutating command joins the previous undo step (later frames of a drag).
join_next: bool,
/// Whether the undo group has a step of its own for the next joined command to merge
/// into. A command that changed nothing leaves the group without one, so the next
/// command in the group starts the step the group needs.
join_anchor: bool,
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
rev: u64,
@@ -672,10 +683,13 @@ impl Session {
}
}
/// Start an editing session at the document beginning with empty history and
/// default view, preferences and a yellow highlighter independent of document undo.
pub fn new(doc: Document) -> Self {
Self::with_registry(doc, Arc::new(crate::cmd::registry()))
}
/// Initialise session defaults while sharing the immutable command registry with previews.
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
let start = doc.start_of(StoryRef::Body);
let bib_style = doc_bib_style(&doc);
@@ -684,6 +698,7 @@ impl Session {
sel: Selection::caret(start),
view: ViewState::default(),
pending: None,
last_highlight: wordcraft_doc::props::Highlight::Yellow,
path: None,
file_name: None,
user_dict_path: None,
@@ -708,6 +723,7 @@ impl Session {
redo: Vec::new(),
typing_open: false,
join_next: false,
join_anchor: false,
autocap: None,
rev: 1,
protect_facts: std::sync::Mutex::new(None),
@@ -995,6 +1011,7 @@ impl Session {
copy.path = self.path.clone();
copy.file_name = self.file_name.clone();
copy.author = self.author.clone();
copy.last_highlight = self.last_highlight;
copy.system_region = self.system_region.clone();
copy.editing_lang = self.editing_lang.clone();
// The copy shares the paragraph layouts, so only what the command changed is laid out.
@@ -1060,10 +1077,11 @@ impl Session {
}
}
/// Snapshot for undo before a change.
pub fn checkpoint(&mut self, label: &str) {
/// Snapshot for undo before a change. Returns whether it took a step: typing that is
/// already in an open step is not a new one.
pub fn checkpoint(&mut self, label: &str) -> bool {
if label == "Typing" && self.typing_open {
return;
return false;
}
self.typing_open = label == "Typing";
self.history.push(Arc::new(Undo {
@@ -1078,6 +1096,9 @@ impl Session {
self.undo_evicted += 1;
}
self.redo.clear();
// The newest step is what a joined command merges into.
self.join_anchor = true;
true
}
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
@@ -1088,6 +1109,7 @@ impl Session {
self.undo_evicted += 1;
}
self.typing_open = false;
self.join_anchor = true;
}
/// Make the next mutating command part of the previous undo step instead of a new one, so a
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
@@ -1217,6 +1239,7 @@ impl Session {
self.history.clear();
self.redo.clear();
self.typing_open = false;
self.join_anchor = false;
}
/// The version snapshots belong to the document they were taken from: keeping the old ones
@@ -1305,7 +1328,8 @@ impl Session {
/// may want to take it back without a trace (cheap: the document's blocks are shared).
///
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
/// the limit), `redo`, the open typing group, a pending `join_next_undo` and `dirty`. `rev`,
/// the limit), `redo`, the open typing group, a pending `join_next_undo` (and whether its
/// group has a step to merge into) and `dirty`. `rev`,
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
/// covered: a caller that lets the command change them (the view, the find state, the
/// clipboard, macros, the file path…) saves and puts them back itself.
@@ -1323,6 +1347,7 @@ impl Session {
sel,
view: _,
pending: _,
last_highlight: _,
path: _,
file_name: _,
// The custom dictionary file, like the file path: host wiring, not an edit.
@@ -1350,6 +1375,7 @@ impl Session {
redo,
typing_open,
join_next,
join_anchor,
autocap: _,
rev: _,
protect_facts: _,
@@ -1404,6 +1430,7 @@ impl Session {
redo: redo.clone(),
typing_open: *typing_open,
join_next: *join_next,
join_anchor: *join_anchor,
dirty: *dirty,
}
}
@@ -1412,7 +1439,7 @@ impl Session {
/// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout
/// is recomputed.
pub fn restore(&mut self, snap: EditSnapshot) {
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, dirty } = snap;
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, join_anchor, dirty } = snap;
let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX);
if gone > 0 {
let back = gone.min(head.len());
@@ -1427,6 +1454,7 @@ impl Session {
self.redo = redo;
self.typing_open = typing_open;
self.join_next = join_next;
self.join_anchor = join_anchor;
self.touch();
self.dirty = dirty;
}
@@ -1459,6 +1487,26 @@ impl Session {
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
}
/// Put the revision back to `rev` after a command that changed nothing or failed. What the
/// command cached at the revision it touched to is dropped: that number comes round again
/// with the next edit, for a different document.
fn restore_rev(&mut self, rev: u64) {
if self.rev == rev {
return;
}
self.rev = rev;
if self.layout.as_ref().is_some_and(|l| l.0 != rev) {
self.layout = None;
}
if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) {
self.find.highlights = None;
}
let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner);
if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) {
*facts = None;
}
}
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
fn protect_facts(&self) -> crate::cmd::protect::Facts {
let key = (self.document_id, self.rev, self.author.clone());
@@ -1473,6 +1521,15 @@ impl Session {
f
}
/// Whether the command changed nothing: the document, the selection, the formatting
/// waiting at the caret and the column selection are as they were before it ran.
///
/// A command that changes nothing is not an edit: it adds no undo step, marks no
/// unsaved changes, forces no relayout and leaves the redo stack alone (#23).
fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option<ColumnBlock>, pending: &Option<CharProps>) -> bool {
doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending
}
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
@@ -1530,10 +1587,25 @@ impl Session {
} else {
None
};
// The formatting waiting at the caret (⌘B with nothing selected): an undo step restores
// it, so a command that only changes it is an edit.
let pending_before = spec.mutates.then(|| self.pending.clone());
// Which document is open. A command that replaces the document (a mail merge) opens a
// new one, with a new undo history: it is not an edit of this document, whatever the
// new document holds.
let document_id_before = self.document_id;
// Whether the document has unsaved changes, and the revision its layout is keyed on: a
// command that changes nothing leaves both as they were, and a failed one leaves no
// trace.
let dirty_before = spec.mutates.then_some(self.dirty);
let rev_restore = spec.mutates.then_some(self.rev);
// These mutating commands can replace the open document and discard its saved versions.
// Keep the versions until the command and the protected-region post-check have both
// succeeded; rejected commands must leave the old document and its snapshots together.
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.finish" | "mailings.envelopes" | "mailings.labels");
// `mailings.finish` with a path saves the merged copy there and leaves the open
// document alone, so it replaces nothing.
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels")
|| (id == "mailings.finish" && crate::p::str(params, "path").is_none());
let versions_before = replaces_document.then(|| self.versions.clone());
let identity_before = replaces_document.then(|| {
(
@@ -1550,10 +1622,15 @@ impl Session {
if spec.mutates && !typing {
self.typing_open = false;
}
// Whether this command pushed an undo step (a no-op command's step is dropped below,
// leaving its undo group without one).
let mut pushed = false;
if spec.mutates {
let label = if typing { "Typing" } else { spec.label };
if !join || self.history.is_empty() {
self.checkpoint(label);
// A joined command merges into its group's step; a group whose first command
// changed nothing has none, so the next command in it starts one (#4001).
if !join || self.history.is_empty() || !self.join_anchor {
pushed = self.checkpoint(label);
}
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
// Non-mutating commands other than caret movement keep the typing group.
@@ -1590,18 +1667,64 @@ impl Session {
}
match &result {
Ok(_) => {
let mut no_op = false;
if spec.mutates {
// Formatting changed while tracking becomes a formatting revision.
if let Some((before, ..)) = &before_doc {
crate::cmd::fmt_revisions::record(self, id, before);
}
self.touch();
self.doc.ensure_nonempty();
self.doc.prune_text_boxes();
// Glued connectors follow the shapes they join.
if crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self) {
// Whether the command left the document, the selection and the formatting
// waiting at the caret as they were. A command that replaces the document —
// a mail merge, a comparison — is not an edit of this one, whatever the new
// document holds: `replaces_document` names the commands that may replace
// it, and `document_id` catches any other that does (through
// `set_document`, which starts a new history the old one must not overwrite).
let unchanged = |s: &Session| match (&before_doc, &pending_before) {
(Some((doc, sel, _, _, _, _, column)), Some(pending)) => {
!replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending)
}
_ => false,
};
let changed = !unchanged(self);
if changed {
// The layout the connectors are rerouted against is the new one.
self.touch();
}
// Glued connectors follow the shapes they join.
let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self);
if rerouted {
self.touch();
}
// A command that changed nothing is not an edit (#23): it adds no undo
// step, marks no unsaved changes, forces no relayout and leaves the redo
// stack alone. What it left behind is compared with what it started from,
// after the reroute: a reroute that moved a connector is an edit of its own.
no_op = !changed && !rerouted && unchanged(self);
if no_op {
// The step the dispatch pushed is not an edit: put the stacks back as
// they were, so the redo stack survives and no step is evicted. The
// group it belonged to is left without a step of its own, so the next
// command in it starts one.
if let Some((_, _, history, redo, typing_open, evicted, _)) = &before_doc {
self.history = history.clone();
self.redo = redo.clone();
self.typing_open = *typing_open;
self.undo_evicted = *evicted;
}
if let Some(dirty) = dirty_before {
self.dirty = dirty;
}
if let Some(rev) = rev_restore {
let touched = self.rev;
self.restore_rev(rev);
crate::cmd::draw::rekey_lasso(self, touched, rev);
}
if pushed {
self.join_anchor = false;
}
}
}
self.clamp_selection();
if sel_before.focus.story == StoryRef::Body && self.in_header_footer() {
@@ -1612,7 +1735,7 @@ impl Session {
if !spec.mutates && self.sel != sel_before {
self.typing_open = false;
}
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") {
if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") {
self.note_edit();
}
// Outline view: collapsed headings follow edits; the caret stays where it shows.
@@ -1649,6 +1772,16 @@ impl Session {
// The steps the command pushed out are back, so they no longer count as gone.
self.undo_evicted = ev;
self.column = c;
// Its step is gone with them: its undo group has none to merge into.
if pushed {
self.join_anchor = false;
}
}
if let Some(dirty) = dirty_before {
self.dirty = dirty;
}
if let Some(rev) = rev_restore {
self.restore_rev(rev);
}
if let Some(b) = blocks_before {
self.building_blocks = b;
+755 -1
View File
@@ -275,6 +275,136 @@ fn typing_enter_undo() {
assert_eq!(text(&s), "Hello world");
}
/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes
/// and no relayout.
#[test]
fn a_command_that_changes_nothing_is_not_an_edit() {
let mut s = s();
let rev = s.rev();
// Backspace at the start of the document: there is nothing to delete.
run(&mut s, "text.backspace", json!({}));
assert_eq!(s.undo_depth(), 0, "no undo step");
assert!(!s.dirty, "no unsaved changes");
assert_eq!(s.rev(), rev, "no relayout");
// Typing nothing is the same.
run(&mut s, "text.insert", json!({"text": ""}));
assert_eq!(s.undo_depth(), 0, "no undo step");
assert!(!s.dirty, "no unsaved changes");
// A command that changes something is an edit.
run(&mut s, "text.insert", json!({"text": "Hello"}));
assert_eq!(s.undo_depth(), 1, "one undo step");
assert!(s.dirty, "the document is edited");
// Setting the alignment a paragraph already has changes nothing.
let depth = s.undo_depth();
run(&mut s, "para.align", json!({"value": "center"}));
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
let depth = s.undo_depth();
run(&mut s, "para.align", json!({"value": "center"}));
assert_eq!(s.undo_depth(), depth, "no undo step");
// A command that changes nothing leaves the unsaved-changes flag and the layout revision as
// they were, even when it calls `touch` itself: a saved document is not marked edited for
// nothing, and its layout is not laid out again.
let mut clean = Session::new(wordcraft_doc::Document::new());
run(&mut clean, "para.align", json!({"value": "left"}));
clean.dirty = false;
let rev = clean.rev();
run(&mut clean, "para.align", json!({"value": "left"}));
assert!(!clean.dirty, "no unsaved changes");
assert_eq!(clean.rev(), rev, "no relayout");
assert_eq!(clean.undo_depth(), 1, "only the first align is a step");
}
/// Five hundred commands that change nothing used to fill the whole undo stack and push the
/// user's own edits out of it, so they could never be undone again (#23).
#[test]
fn no_op_commands_do_not_evict_the_undo_history() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "caret.docStart", json!({}));
for _ in 0..600 {
run(&mut s, "text.backspace", json!({}));
}
assert_eq!(s.undo_depth(), 1, "the typing step is still there");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "", "the typed text is still undoable");
}
/// A command that changes nothing does not throw the redo stack away (#23).
#[test]
fn a_no_op_command_keeps_the_redo_stack() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "edit.undo", json!({}));
assert!(s.can_redo());
run(&mut s, "text.backspace", json!({}));
assert!(s.can_redo(), "the redo stack survives a command that changed nothing");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "Hello");
}
/// Undo and Redo move between steps even when a step's document is the one showing: they are
/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step.
#[test]
fn undo_moves_past_a_step_that_changed_nothing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
s.checkpoint("Nothing");
let depth = s.undo_depth();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.undo_depth(), depth - 1);
assert!(s.can_redo());
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.undo_depth(), depth);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "");
}
/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag):
/// a group whose first command changes nothing has no step to merge into, so the next command
/// in it starts the group's step instead of merging into an older edit (#23 follow-up).
#[test]
fn a_joined_command_starts_its_group_when_the_first_changed_nothing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top));
// Portrait on a portrait page changes nothing.
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit");
// The next command of the group starts the step the group needs.
s.join_next_undo();
run(&mut s, "layout.margins", json!({"left": 100.0}));
assert_eq!(s.doc.last_section.margin_left, 100.0);
assert_eq!(s.undo_depth(), depth + 1, "the group's one step");
// A command that changes nothing in the middle of the group leaves that step alone, and
// the next one merges into it.
s.join_next_undo();
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
s.join_next_undo();
run(&mut s, "layout.margins", json!({"top": 60.0}));
assert_eq!(s.undo_depth(), depth + 1, "still the group's one step");
// Undo takes the whole group back and no more: the typing is still there.
run(&mut s, "edit.undo", json!({}));
assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from");
assert_eq!(text(&s), "Hello");
}
/// Bold with nothing selected arms the formatting for the next word: the document is the
/// same, so only the caret's formatting changed — and that is an edit (#23).
#[test]
fn a_command_that_only_arms_the_caret_formatting_is_an_edit() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "caret.docStart", json!({}));
let depth = s.undo_depth();
run(&mut s, "format.bold", json!({}));
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
// Undo takes the armed formatting back, as Word does (docs/typing-parity.md).
run(&mut s, "edit.undo", json!({}));
run(&mut s, "text.insert", json!({"text": "x"}));
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None));
}
/// The Undo button's list (#701): several steps undone at once, newest first.
#[test]
fn undo_to_takes_several_steps_back_at_once() {
@@ -514,7 +644,10 @@ fn failed_command_restores_history_changed_by_nested_commands() {
// Comments are stamped with the time to the second, and the two sessions compared
// are built a moment apart, so leave the stamp out of the comparison.
let mut doc = s.doc.clone();
doc.comments.values_mut().for_each(|c| c.date.clear());
doc.comments.values_mut().for_each(|c| {
c.date.clear();
c.date_utc = None;
});
out.push((s.undo_labels(), doc));
if !s.can_undo() {
return out;
@@ -1674,6 +1807,70 @@ fn tables_commands() {
assert!(s.run("table.merge", &json!({})).is_err());
}
#[test]
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let widths = |s: &Session| {
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
(t.grid.clone(), t.rows[0].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect::<Vec<_>>())
};
let (grid_before, _) = widths(&s);
assert!(grid_before.iter().all(|w| *w > 0.0));
run(&mut s, "table.insertColumnRight", json!({}));
let (grid_after, cells) = widths(&s);
// The table keeps its total width, and each cell carries its grid columns' width: the
// layout and the saved `w:tcW` see the same shrink the grid got.
let sum = |v: &[f32]| v.iter().sum::<f32>();
assert!((sum(&grid_after) - sum(&grid_before)).abs() < 0.5, "{grid_after:?} vs {grid_before:?}");
assert_eq!(cells.len(), grid_after.len());
for (g, w) in grid_after.iter().zip(cells.iter()) {
assert!((w - g).abs() < 0.01, "cell width {w} vs grid {g}");
}
}
/// A row that skips grid columns (`w:gridBefore`) gives its cells the widths of the columns
/// they sit in, not of the first ones.
#[test]
fn cell_widths_follow_the_grid_after_skipped_columns() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
let i = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap();
let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(&mut s.doc.body[i]) else { panic!("no table") };
t.grid = vec![100.0, 200.0, 300.0];
t.rows[1].props.grid_before = 1;
t.rows[1].cells.pop();
crate::cmd::table::sync_cell_widths(t);
let widths: Vec<f32> = t.rows[1].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect();
assert_eq!(widths, [200.0, 300.0]);
}
#[test]
fn a_rejected_version_restore_saves_nothing() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Original text."));
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
s.run("file.versions", &json!({"save": "before"})).unwrap();
// One command asking for both a save and a restore the protection refuses: the save must
// not happen either.
let e = s.run("file.versions", &json!({"save": "mid", "restore": 0})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
// A bad index is refused before anything is saved, too.
let e = s.run("file.versions", &json!({"save": "mid", "restore": 99})).unwrap_err().to_string();
assert!(e.contains("no such version"), "{e}");
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
// A restore that is there but not a non-negative integer is refused, not read as absent:
// {"save": "mid", "restore": -1} must not save quietly without restoring.
let e = s.run("file.versions", &json!({"save": "mid", "restore": -1})).unwrap_err().to_string();
assert!(e.contains("restore (non-negative integer)"), "{e}");
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
// An index too large for `usize` names no version: on a 32-bit target `as usize` would wrap
// it to 0 and restore the first version instead.
let e = s.run("file.versions", &json!({"save": "mid", "restore": 4294967296u64})).unwrap_err().to_string();
assert!(e.contains("no such version"), "{e}");
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
}
/// #3931: the page as a command would leave it, laid out without running it on the document:
/// Insert › Table's grid previews the table it would insert. The document, the caret, the undo
/// history and Repeat stay as they were.
@@ -1772,6 +1969,65 @@ fn delete_row_on_a_merge_start_promotes_the_cell_below() {
assert_eq!(vm(&s, 1), VMerge::Continue);
}
/// Split Table under a vertically merged cell: the merge ends in the first table and starts
/// again in the second.
#[test]
fn split_table_ends_and_restarts_a_merge_it_cuts() {
use wordcraft_doc::props::VMerge::{self, Continue, None as No, Restart};
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let tables = |s: &Session| -> Vec<Vec<Vec<VMerge>>> {
let rows = |t: &wordcraft_doc::Table| t.rows.iter().map(|r| r.cells.iter().map(|c| c.props.vmerge).collect()).collect();
s.doc.body.iter().filter_map(|b| b.as_table()).map(rows).collect()
};
let merged = |last: u32| {
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(last, 0)}));
run(&mut s, "table.merge", json!({}));
s
};
// Rows 0 to 2 merged, split at row 1: the second table's merge covers rows 1 and 2.
let mut s = merged(2);
let before = tables(&s);
run(&mut s, "caret.set", json!({"pos": cell(1, 1)}));
run(&mut s, "table.splitTable", json!({}));
assert_eq!(tables(&s), [vec![vec![No, No]], vec![vec![Restart, No], vec![Continue, No], vec![No, No]]]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(tables(&s), before);
// Rows 0 and 1 merged, split at row 1: neither part keeps a merge.
let mut s = merged(1);
run(&mut s, "caret.set", json!({"pos": cell(1, 1)}));
run(&mut s, "table.splitTable", json!({}));
assert_eq!(tables(&s), [vec![vec![No, No]], vec![vec![No, No], vec![No, No], vec![No, No]]]);
}
/// Split Table inside a row content control (a repeating section) around several rows: the
/// control closes after the first table and opens again before the second.
#[test]
fn split_table_keeps_a_row_control_on_both_parts() {
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let control = |tag: &str| wordcraft_doc::ContentControl { id: Some(7), tag: tag.into(), ..Default::default() };
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
if let Some(b) = s.doc.body.iter_mut().find(|b| b.as_table().is_some())
&& let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(b)
{
// "outer" goes around every row, and "inner" goes around row 1 alone.
t.rows[0].controls.open = vec![control("outer")];
t.rows[1].controls.open = vec![control("inner")];
t.rows[1].controls.close = 1;
t.rows[3].controls.close = 1;
}
run(&mut s, "caret.set", json!({"pos": cell(2, 0)}));
run(&mut s, "table.splitTable", json!({}));
let wraps = |t: &wordcraft_doc::Table| -> Vec<(Vec<String>, u32)> {
t.rows.iter().map(|r| (r.controls.open.iter().map(|c| c.tag.clone()).collect(), r.controls.close)).collect()
};
let parts: Vec<&wordcraft_doc::Table> = s.doc.body.iter().filter_map(|b| b.as_table()).collect();
assert_eq!(parts.len(), 2);
assert_eq!(wraps(parts[0]), [(vec!["outer".to_string()], 0), (vec!["inner".to_string()], 2)], "the first table closes the control");
assert_eq!(wraps(parts[1]), [(vec!["outer".to_string()], 0), (vec![], 1)], "the second table opens it again");
assert_eq!(parts[1].rows[0].controls.open[0].id, None, "the copy doesn't repeat the control's id");
}
fn cells(s: &Session) -> Option<((usize, usize), (usize, usize))> {
s.cell_range().map(|x| (x.rows, x.cols))
}
@@ -2873,6 +3129,40 @@ fn toc_and_fields() {
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
}
/// A comment or reply made here carries its UTC time as `date_utc` too, so it shows in local time
/// and is saved as `dateUtc`.
#[test]
fn new_comments_and_replies_carry_their_utc_time() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Some text"}));
run(&mut s, "select.all", json!({}));
let id = run(&mut s, "review.newComment", json!({"text": "one"}))["id"].as_u64().unwrap();
run(&mut s, "review.reply", json!({"id": id, "text": "two"}));
assert_eq!(s.doc.comments.len(), 2);
for c in s.doc.comments.values() {
assert!(c.date.ends_with('Z'), "{}", c.date);
assert_eq!(c.date_utc.as_deref(), Some(c.date.as_str()));
}
}
/// Update Table with nothing to change lays the document out at the revisions it touches, then
/// puts the revision back. The next edits reach those numbers again with a different document, so
/// the layout must be the new one.
#[test]
fn a_command_that_changes_nothing_leaves_no_stale_layout() {
let mut s = Session::new(crate::sample::report());
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "references.toc", json!({}));
let (rev, steps) = (s.rev(), s.undo_depth());
let pages = s.layout().pages.len();
run(&mut s, "references.updateToc", json!({}));
assert_eq!((s.rev(), s.undo_depth()), (rev, steps), "Update Table changed nothing");
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.insert", json!({"text": "A"}));
run(&mut s, "text.insert", json!({"text": "word ".repeat(4000)}));
assert!(s.layout().pages.len() > pages, "the layout has the inserted text");
}
#[test]
fn toc_refresh_after_save_and_reopen() {
for extension in ["docx", "json"] {
@@ -3089,6 +3379,172 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
}
}
/// A paragraph style `id` named `name`, based on Normal.
fn style_xml(id: &str, name: &str, ppr: &str, rpr: &str) -> String {
format!(
r#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{name}"/><w:basedOn w:val="Normal"/><w:pPr>{ppr}</w:pPr><w:rPr>{rpr}</w:rPr></w:style>"#
)
}
/// A DOCX package with `body` and the paragraph `styles` after a default Normal style.
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
use std::io::Write;
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
const RELS: &str = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
const TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, value) in [
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
(
"word/styles.xml",
format!(
r#"<w:styles {W}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w:styles>"#
),
),
(
"word/_rels/document.xml.rels",
format!(r#"<Relationships {RELS}><Relationship Id="rId1" Type="{TYPE}/styles" Target="styles.xml"/></Relationships>"#),
),
(
"_rels/.rels",
format!(r#"<Relationships {RELS}><Relationship Id="main" Type="{TYPE}/officeDocument" Target="word/document.xml"/></Relationships>"#),
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(value.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// #3891: Update Table on a TOC Word wrote in Portuguese keeps the formatting of its TOC styles,
/// stored under the ids `Sumrio1`/`Sumrio3` (`toc 1`/`toc 3`): bold Arial at level 1, italic and
/// indented at level 3.
#[test]
fn update_table_keeps_the_formatting_of_localized_toc_styles() {
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let body = [
p("CabealhodoSumrio", &t("Contents")),
p("Sumrio1", &format!("{begin}{}", t("Chapter\t1"))),
p("Sumrio3", &t("Detail\t1")),
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
p("Ttulo1", &t("Chapter")),
p("Ttulo3", &t("Detail")),
]
.concat();
let styles = [
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
style_xml("Ttulo3", "heading 3", r#"<w:outlineLvl w:val="2"/>"#, ""),
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
style_xml("Sumrio1", "toc 1", "", r#"<w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:b/>"#),
style_xml("Sumrio3", "toc 3", r#"<w:ind w:left="220"/>"#, "<w:i/>"),
]
.concat();
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
run(&mut s, "references.updateToc", json!({}));
let entries: Vec<(String, bool, bool, f32)> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
.map(|p| {
let c = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
(c.font, c.bold, c.italic, s.doc.styles.resolve_para(&p.props).indent_left)
})
.collect();
let level3 = entries.get(1).map(|e| e.0.clone()).unwrap_or_default();
assert_eq!(entries, [("Arial".to_string(), true, false, 0.0), (level3, false, true, 11.0)], "{}", text(&s));
}
/// #3889: Word's TOC in two columns, written by Word in Portuguese. The TOC heading ends a
/// one-column section; the entries, styled `Sumrio1`/`Sumrio2` (`toc 1`/`toc 2`), fill a continuous
/// two-column one, and the field's end and the control's end share a paragraph after them. Update
/// Table keeps the entries' styles, puts the page numbers at the first column's right edge (3888
/// twips, as Word's own entries have them) and adds no blank line above the first column; update,
/// save and reopen keeps it so.
#[test]
fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table() {
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
let tab = r#"<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="3888"/></w:tabs>"#;
let entry = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/>{tab}</w:pPr>{inner}</w:p>"#);
let sect = |cols: &str, kind: &str| {
format!(
r#"<w:sectPr>{kind}<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1417" w:right="1701" w:bottom="1417" w:left="1701" w:header="708" w:footer="708" w:gutter="0"/><w:cols {cols} w:space="708"/></w:sectPr>"#
)
};
let continuous = r#"<w:type w:val="continuous"/>"#;
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-2" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let toc = [
p("CabealhodoSumrio", &t("Contents")),
format!("<w:p><w:pPr>{}</w:pPr></w:p>", sect("", "")),
entry("Sumrio1", &format!("{begin}{}", t("Chapter One\t1"))),
entry("Sumrio2", &t("Section\t1")),
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
]
.concat();
let body = [
format!(
r#"<w:sdt><w:sdtPr><w:id w:val="-1360965913"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>{toc}</w:sdtContent></w:sdt>"#
),
format!(r#"<w:p><w:pPr>{}</w:pPr></w:p>"#, sect(r#"w:num="2""#, continuous)),
p("Ttulo1", &t("Chapter One")),
p("Ttulo2", &t("Section")),
sect("", ""),
]
.concat();
let styles = [
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
style_xml("Ttulo2", "heading 2", r#"<w:outlineLvl w:val="1"/>"#, ""),
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
style_xml("Sumrio1", "toc 1", r#"<w:spacing w:after="100"/>"#, ""),
style_xml("Sumrio2", "toc 2", r#"<w:spacing w:after="100"/><w:ind w:left="220"/>"#, ""),
]
.concat();
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
let mut blocks = None;
for round in 0..3 {
run(&mut s, "references.updateToc", json!({}));
let entries: Vec<(usize, &wordcraft_doc::Paragraph)> = s
.doc
.body
.iter()
.enumerate()
.filter_map(|(i, b)| b.as_para().map(|p| (i, p)))
.filter(|(_, p)| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
.collect();
// The chapters' section starts a new page.
let found: Vec<(Option<&str>, String)> = entries.iter().map(|(_, p)| (p.props.style.as_deref(), p.plain_text())).collect();
assert_eq!(found, [(Some("TOC1"), "Chapter One\t2".to_string()), (Some("TOC2"), "Section\t2".to_string())], "round {round}: {}", text(&s));
assert_eq!(s.doc.styles.get("TOC2").and_then(|st| st.para.indent_left), Some(11.0), "round {round}: the document's own TOC 2");
for (_, e) in &entries {
let right = e.props.tabs.as_ref().and_then(|t| t.iter().find(|t| t.align == wordcraft_doc::props::TabAlign::Right)).unwrap();
assert!((right.pos - 194.4).abs() < 0.1, "round {round}: page numbers at the first column's edge, not the page's: {}", right.pos);
}
// The paragraph above the first entry ends the one-column section: no blank line tops the
// first column.
let above = entries[0].0 - 1;
assert!(s.doc.body[above].as_para().unwrap().section.is_some(), "round {round}: a blank line above the entries: {}", text(&s));
let bytes = wordcraft_docx::write(&s.doc).unwrap();
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "round {round}: the document grew: {}", text(&s));
blocks = Some(s.doc.body.len());
}
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
let columns = |s: &Session| s.doc.sections().iter().map(|(_, x)| x.columns.count).collect::<Vec<_>>();
assert_eq!(columns(&s), [1, 2, 1]);
// The heading goes with the table: one "Contents" after Replace, none after Remove.
let titles = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.plain_text() == "Contents").count();
run(&mut s, "references.toc", json!({"levels": 2}));
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
assert_eq!(titles(&s), 1, "{}", text(&s));
run(&mut s, "references.removeToc", json!({}));
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
assert_eq!(titles(&s), 0, "{}", text(&s));
}
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
/// range TOC doesn't claim the menu, so it never refreshes a different table.
#[test]
@@ -5181,6 +5637,32 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
assert_eq!(s.view.read_line_spacing, None);
}
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
/// document alone, is not saved, and starts at None on each entry to Read Mode.
#[test]
fn reading_page_color_is_a_transient_view_setting() {
use crate::cmd::view::ReadPageColor;
let mut s = s();
let original = s.doc.clone();
let dirty = s.dirty;
run(&mut s, "view.readMode", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::None);
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
}
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
assert_eq!(s.doc, original);
assert_eq!(s.dirty, dirty);
run(&mut s, "view.printLayout", json!({}));
run(&mut s, "view.readMode", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::None);
}
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
#[test]
fn numeral_mode_sets_gets_and_rejects_junk() {
@@ -6548,6 +7030,210 @@ fn convert_text_to_table_keeps_inline_content() {
assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion");
}
/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the
/// last cell.
fn typed_table(cols: usize, cells: &[&str]) -> Session {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols}));
for (k, c) in cells.iter().enumerate() {
if k > 0 {
run(&mut s, "text.tab", json!({}));
}
if !c.is_empty() {
run(&mut s, "text.insert", json!({"text": c}));
}
}
s
}
/// The body as `T` for a table and the text of each paragraph.
fn body_outline(s: &Session) -> Vec<String> {
s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect()
}
fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value {
json!({"story": "body", "path": [table, r, c, 0], "off": 0})
}
/// Convert to Text sets a row's cells apart with a tab, a comma, another character or
/// paragraph marks; rows are paragraphs.
#[test]
fn convert_to_text_takes_each_separator() {
let cases = [
(json!({}), vec!["a\tb", "c\td"]),
(json!({"separator": "tab"}), vec!["a\tb", "c\td"]),
(json!({"separator": "comma"}), vec!["a,b", "c,d"]),
(json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]),
(json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]),
];
for (params, want) in cases {
let mut s = typed_table(2, &["a", "b", "c", "d"]);
run(&mut s, "table.toText", params.clone());
let got = body_outline(&s);
assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::<Vec<_>>()[..]), "{params}: {got:?}");
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}");
}
// U+FFFC marks an object in a paragraph's text, so it cannot be the separator.
let obj = wordcraft_doc::para::OBJ.to_string();
for bad in [
json!({"separator": "other"}),
json!({"separator": "other", "char": "\n"}),
json!({"separator": "other", "char": obj}),
json!({"separator": "pipe"}),
] {
let mut s = typed_table(2, &["a", "b", "c", "d"]);
let depth = s.undo_depth();
assert!(s.run("table.toText", &bad).is_err(), "{bad}");
assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays");
assert_eq!(s.undo_depth(), depth);
}
}
/// With rows selected, only those become text; the rows above and below stay tables, and a
/// vertical merge cut by the selection starts again below it.
#[test]
fn convert_to_text_converts_the_selected_rows() {
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
// Column 1 merged down rows 1 to 3.
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)}));
run(&mut s, "table.merge", json!({}));
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
// The merged cell holds d, f and h, one paragraph each.
assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s));
let head = s.doc.body[at].as_table().unwrap();
let tail = s.doc.body[at + 4].as_table().unwrap();
assert_eq!((head.rows.len(), tail.rows.len()), (1, 2));
use wordcraft_doc::props::VMerge;
let merges: Vec<VMerge> = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect();
assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left");
assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text");
// A merge that went on into the converted rows ends in the last row above them.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)}));
run(&mut s, "table.merge", json!({}));
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)}));
run(&mut s, "table.toText", json!({}));
let head = s.doc.body[t as usize].as_table().unwrap();
assert_eq!(head.rows.len(), 1);
assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table");
// A floating table keeps its place in the part above; the part below goes in the text flow.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() };
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float);
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float));
assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above");
// The caret alone in a cell converts every row, as before.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
run(&mut s, "table.toText", json!({}));
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()));
}
/// A content control around the table keeps both its ends when the first rows, the last rows
/// or every row become text.
#[test]
fn convert_to_text_keeps_a_content_control_around_the_table() {
use wordcraft_doc::{ContentControl, ControlWrap, InlineObject};
let has = |p: &wordcraft_doc::Paragraph, start: bool| {
p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) })
};
for rows in [Some((0, 0)), Some((2, 2)), None] {
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls =
ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 };
assert!(!s.doc.has_unbalanced_controls());
match rows {
Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})),
None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})),
};
run(&mut s, "table.toText", json!({}));
assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s));
let at = t as usize;
if rows != Some((2, 2)) {
assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text");
}
if rows != Some((0, 0)) {
let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap();
assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text");
}
}
// A control around rows 0 to 3, row 1 converted: the table above closes it, the table below
// opens it again.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
{
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap();
table.rows[0].controls.open = vec![ContentControl { id: Some(7), tag: "rows".into(), ..Default::default() }];
table.rows[3].controls.close = 1;
}
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
let head = s.doc.body[at].as_table().unwrap();
let tail = s.doc.body[at + 2].as_table().unwrap();
assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control");
assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::<Vec<_>>(), ["rows"], "the table below opens it");
assert_eq!((head.rows[0].controls.open[0].id, tail.rows[0].controls.open[0].id), (Some(7), None), "one w:id in the file");
assert_eq!(tail.rows[1].controls.close, 1);
assert!(!s.doc.has_unbalanced_controls());
}
/// The text keeps its formatting, and a table nested in a cell stays a table.
#[test]
fn convert_to_text_keeps_formatting_and_nested_tables() {
let mut s = typed_table(2, &["Name", "X"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0});
let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4});
run(&mut s, "select.range", json!({"anchor": first, "focus": end}));
run(&mut s, "format.bold", json!({}));
// A table nested in the second cell, after its text.
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 });
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Inner"}));
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 });
run(&mut s, "table.toText", json!({}));
let at = t as usize;
let line = s.doc.body[at].as_para().unwrap();
assert_eq!(line.text, "Name\tX");
assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold");
assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting");
let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table");
assert_eq!(table_cell_text(inner), "Inner");
// A selected row whose first cell starts with a nested table: the caret goes to the row's
// text, not back into the table above.
let mut s = typed_table(2, &["A", "B", "", "D"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 });
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
let row = &s.doc.body[t as usize].as_table().unwrap().rows[1];
assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table");
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)}));
s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 };
run(&mut s, "table.toText", json!({}));
let at = t as usize;
assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s));
assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text");
}
fn table_cell_text(t: &wordcraft_doc::Table) -> String {
t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()
}
#[test]
fn delete_columns_deletes_every_selected_column() {
// Issue #431: only the selection's focus column went.
@@ -8089,6 +8775,51 @@ fn update_fields_recalculates_a_table_formula() {
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("21"));
}
/// A formula's `\*` switches format its result when the formula is inserted and when Update Field
/// works it out again.
#[test]
fn table_formula_results_take_the_format_switches() {
for (formula, inserted, updated) in [
(r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"),
(r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"),
(r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"),
(r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"),
] {
let mut s = formula_table();
let r = run(&mut s, "table.formula", json!({"formula": formula}));
assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted)));
run(
&mut s,
"select.range",
json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}),
);
run(&mut s, "text.insert", json!({"text": "10"}));
run(&mut s, "references.updateFields", json!({}));
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}");
}
}
#[test]
fn table_formula_results_take_dollar_text_and_hex() {
for (formula, inserted, updated) in [
(r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"),
(r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"),
(r"=SUM(ABOVE) \* Hex", "C", "15"),
] {
let mut s = formula_table();
let r = run(&mut s, "table.formula", json!({"formula": formula}));
assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted)));
run(
&mut s,
"select.range",
json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}),
);
run(&mut s, "text.insert", json!({"text": "10"}));
run(&mut s, "references.updateFields", json!({}));
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}");
}
}
/// A one-column table (no header row) holding `cells`, in a document whose default language
/// is `lang`; the caret ends in the last cell.
fn column_table(lang: &str, cells: &[&str]) -> Session {
@@ -8753,6 +9484,29 @@ fn a_version_snapshot_does_not_survive_an_untitled_comparison() {
}
}
/// A comparison with an `original` replaces the open document in place — the handler loads the
/// original into `s.doc` without `set_document` — so `document_id` does not move and two
/// identical comparisons produce identical documents. A comparison is an edit of its own all
/// the same: the `replaces_document` guard keeps its undo step (#4004 review).
#[test]
fn a_repeated_comparison_is_still_an_edit() {
let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("original.docx");
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."});
s.run("review.compare", &compare).unwrap();
let depth = s.undo_depth();
assert!(s.dirty);
s.run("review.compare", &compare).unwrap();
assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own");
assert!(s.dirty, "the comparison result is unsaved");
let _ = std::fs::remove_dir_all(&dir);
}
/// A rejected comparison leaves the current document and its snapshots together.
#[test]
fn a_rejected_comparison_keeps_the_current_versions() {
@@ -401,3 +401,74 @@ fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() {
assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}");
}
}
#[derive(Clone, Copy, Debug)]
enum Slot {
FootnoteSeparator,
EndnoteSeparator,
FootnoteContinuationSeparator,
EndnoteContinuationNotice,
}
const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice];
fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option<wordcraft_doc::Blocks> {
match slot {
Slot::FootnoteSeparator => &mut doc.footnote_separators.separator,
Slot::EndnoteSeparator => &mut doc.endnote_separators.separator,
Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator,
Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice,
}
}
/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0).
fn tracked_separator_session(slot: Slot) -> Session {
let mut doc = wordcraft_doc::Document::new();
doc.revisions.push(wordcraft_doc::Revision {
kind: wordcraft_doc::RevisionKind::Insert,
author: "Alice".into(),
date: String::new(),
move_name: None,
});
let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() };
let p = wordcraft_doc::Paragraph::with_text("SEP", props);
*slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]);
Session::new(doc)
}
fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph {
slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone()
}
#[test]
fn accept_all_resolves_note_separator_revisions() {
for slot in SLOTS {
let mut s = tracked_separator_session(slot);
run(&mut s, "review.acceptAll", json!({}));
let p = separator_para(&mut s, slot);
assert_eq!(p.plain_text(), "SEP", "{slot:?}");
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
}
}
#[test]
fn reject_all_resolves_note_separator_revisions() {
for slot in SLOTS {
let mut s = tracked_separator_session(slot);
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}");
}
}
#[test]
fn new_tracked_change_does_not_alias_separator_revision() {
for slot in SLOTS {
let mut s = tracked_separator_session(slot);
run(&mut s, "review.acceptAll", json!({}));
s.author = "Bob".into();
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.insert", json!({"text": "NEW "}));
let p = separator_para(&mut s, slot);
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
}
}
+792
View File
@@ -8,10 +8,12 @@ use wordcraft_doc::{Pos, StoryRef};
use crate::Session;
/// Create an empty session for command-driven typing fixtures.
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
/// Execute a registry command and report its id on a test failure.
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
@@ -39,10 +41,12 @@ fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
.collect()
}
/// Expected paragraph text and list membership for typing assertions.
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
(text.to_string(), level, level.is_some())
}
/// Create a session and enter its fixture through the actual typing commands.
fn typed(keys: &str) -> Session {
let mut s = s();
typ(&mut s, keys);
@@ -315,6 +319,29 @@ fn list_autoformat_does_not_override_a_style_linked_heading_number() {
assert_eq!(paras(&s)[1].0, "- Two", "the trigger stays as typed text, not stripped into a bullet");
}
/// A heading numbered only through its style's linked Multilevel List keeps "---" and "+--+--+"
/// as text on Enter, as a paragraph numbered directly does: no border, no table, and at the top
/// of the document the heading keeps its number.
#[test]
fn border_and_table_lines_stay_text_in_a_style_numbered_heading() {
for (above, keys) in [("", "---"), ("Above\n", "---"), ("", "+--+--+"), ("Above\n", "+--+--+")] {
let mut s = s();
typ(&mut s, &format!("{above}{keys}"));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "list.multilevelPreset", json!({"preset": "headings"}));
let at = Pos::body(s.doc.body.len() - 1, 0);
assert_eq!(s.doc.para_at(&at).unwrap().props.numbering, None, "numbered through the style, not directly");
typ(&mut s, "\nText");
assert_eq!(s.doc.plain_text(StoryRef::Body), format!("{above}{keys}\nText"), "{above:?}{keys}");
assert!(s.doc.body.iter().all(|b| b.as_para().is_some_and(|p| p.props.borders.is_none())), "{above:?}{keys}");
let mut want = vec![num(Some("1"), "Heading1"), num(None, "Normal")];
if !above.is_empty() {
want.insert(0, num(None, "Normal"));
}
assert_eq!(numbers(&mut s), want, "{above:?}{keys}");
}
}
#[test]
fn list_autoformat_triggers() {
let s = typed("- Item\nNext");
@@ -489,6 +516,25 @@ fn enter_finishes_a_word_for_autocorrect() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n");
}
/// A word after a no-break space or an ideographic space is corrected as after a plain space:
/// replacements, sentence capitals, ordinals and links.
#[test]
fn autocorrect_finds_the_word_after_a_wide_space() {
let s = typed("a\u{a0}teh x\u{3000}teh ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the ");
let s = typed("end.\u{a0}hello end.\u{3000}world ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World ");
let s = typed("the\u{a0}1st and\u{3000}22nd ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript);
assert!(sup("1st") && sup("22nd"));
let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone();
assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log"));
assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day"));
}
#[test]
fn ordinals_become_superscript() {
let s = typed("the 1st and 22nd and 13th but not 1th ");
@@ -501,6 +547,174 @@ fn ordinals_become_superscript() {
assert!(!sup("1th"));
}
#[test]
fn bold_and_italic_marks_become_formatting() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false}));
typ(&mut s, "say *very* nice, _quite_ good *two words*. ok\n*end*\nnext");
assert_eq!(s.doc.plain_text(StoryRef::Body), "say very nice, quite good two words. ok\nend\nnext");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = |t: &str| p.props_of_char(p.text.find(t).unwrap());
assert_eq!((at("very").bold, at("very").italic), (Some(true), None));
assert_eq!((at("quite").italic, at("quite").bold), (Some(true), None));
// Complex-script text (Arabic, Hebrew) takes the formatting too.
assert_eq!((at("very").bold_cs, at("quite").italic_cs), (Some(true), Some(true)));
assert_eq!(at("two").bold, Some(true));
assert_eq!(at("words").bold, Some(true));
for plain in ["say", " nice", ", ", " good", ". ok"] {
assert_eq!((at(plain).bold, at(plain).italic), (None, None), "{plain:?}");
}
// Enter finishes the marked word too, and the next paragraph is not bold.
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert_eq!((p.props_of_char(0).bold, p.mark.bold), (Some(true), None));
let p = s.doc.para_at(&Pos::body(2, 0)).unwrap();
assert_eq!((p.props_of_char(0).bold, p.mark.bold), (None, None));
// With Track Changes on, the paragraph marks carry the same revisions as for unmarked text.
let marks = |keys: &str| {
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, keys);
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.mark.ins.is_some(), p.mark.del.is_some())).collect::<Vec<_>>()
};
assert_eq!(marks("x *end*\n"), marks("x end\n"));
// After a no-break space too.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "a\u{a0}*word* ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}word ");
// A red word between plain marks: the next paragraph takes the closing mark's formatting.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "*");
run(&mut s, "format.color", json!({"color": "FF0000"}));
typ(&mut s, "red");
run(&mut s, "format.color", json!({"color": "auto"}));
typ(&mut s, "*\nnext");
let red = s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).color;
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert!(red.is_some());
assert_ne!(p.mark.color, red);
assert_ne!(p.props_of_char(0).color, red);
// A heading's next style starts afresh after Enter, as without the marks.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "format.bold", json!({}));
typ(&mut s, "*title*\n");
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert_eq!((p.props.style.as_deref(), p.mark.bold), (Some("Normal"), None));
// Marks typed before Track Changes was turned on stay: removing them would not be tracked.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "x *end*");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, " ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* ");
// Marks the same author typed with Track Changes on are converted.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "x *end* ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "X end ");
// Marks another author inserted stay.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "file.setAuthor", json!({"name": "Ana"}));
typ(&mut s, "x *end*");
run(&mut s, "file.setAuthor", json!({"name": "Ben"}));
typ(&mut s, " ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* ");
// Undo right after brings the marks back.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "a *b* ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "A b ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b* ");
// After Enter, Undo brings the marks back and the Enter stays.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "a *b*\n");
assert_eq!(s.doc.plain_text(StoryRef::Body), "A b\n");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b*\n");
}
#[test]
fn autocorrect_goes_on_after_bold_and_italic_marks() {
// After a space or punctuation, the word without its marks is replaced and capitalized.
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}}));
typ(&mut s, "*teh* end. _wc_.");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. WordCraft.");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone();
assert_eq!((at("The").bold, at("WordCraft").italic, at(" end").bold), (Some(true), Some(true), None));
// One Undo brings the marks back as typed.
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. _wc_.");
// A marked web address becomes a link, and the text typed after it is neither bold nor italic.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "see *www.example.org* and _www.example.com_ next ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone();
assert_eq!((at("www.example.org").bold, at("www.example.org").link.is_some()), (Some(true), true));
assert_eq!((at("www.example.com").italic, at("www.example.com").link.is_some()), (Some(true), true));
for plain in [" and", " next"] {
assert_eq!((at(plain).bold, at(plain).italic, at(plain).link.is_some()), (None, None, false), "{plain:?}");
}
// After Enter too; a longer replacement moves the Enter with it, and Undo keeps the Enter.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}}));
typ(&mut s, "*teh*\nok. *wc*\nnext");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The\nOk. WordCraft\nnext");
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert_eq!(p.props_of_char(p.text.find("WordCraft").unwrap()).bold, Some(true));
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "*teh*\n");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "*teh*\n");
// In a span of several words, the first word is replaced and capitalized as it is typed.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "*teh end* and _teh rest_ ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The end and the rest ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone();
assert_eq!((at("The end").bold, at("the rest").italic, at(" and").bold), (Some(true), Some(true), None));
}
#[test]
fn bold_and_italic_marks_stay_as_typed_unless_they_enclose_words() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false}));
let keys = "2*3* file_name_ * x* *x * ** __ *?!* _-_ ";
typ(&mut s, keys);
assert_eq!(s.doc.plain_text(StoryRef::Body), keys);
// Marks stay while formatting is limited to the document's styles.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true}));
typ(&mut s, "*word* ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "*word* ");
// Marks around an object (here a check box content control) stay.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"boldItalic": true}));
typ(&mut s, "x *");
run(&mut s, "developer.checkBox", json!({}));
run(&mut s, "caret.docEnd", json!({}));
typ(&mut s, "* ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text.matches('*').count(), 2, "{:?}", p.text);
// Off by default.
let s = typed("*bold* _italic_ ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "*bold* _italic_ ");
}
#[test]
fn web_addresses_become_links() {
let mut s = typed("see https://example.org/log, then ");
@@ -568,6 +782,71 @@ fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() {
assert_eq!(p.props_of_char(m).sym_font, None);
}
/// Whether every character of the body paragraph `i` is marked deleted.
fn all_deleted(s: &Session, i: usize, text: &str) -> bool {
s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some()))
}
#[test]
fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some());
// Characters that were there before Track Changes was turned on are marked deleted.
let mut s = typed("Above\n---");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\n");
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s));
// Enter again after the deleted characters is a plain Enter.
typ(&mut s, "\n");
assert_eq!(texts(&s), ["above", "---", ""]);
assert_eq!(s.sel.focus, Pos::body(2, 0));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["above", "---"]);
assert!(bottom(&s, 0).is_none());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(texts(&s), ["above", ""]);
assert!(bottom(&s, 0).is_some());
// At the top of the document the border goes on the characters' paragraph, and typing
// continues in a new one.
let mut s = typed("---");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\nText");
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s));
assert_eq!(texts(&s)[1], "text");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["---"]);
assert!(bottom(&s, 0).is_none());
// Typed with Track Changes on, the characters go and the border is still a change.
let mut s = typed("");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "---\n");
assert_eq!(texts(&s), ["", ""]);
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
run(&mut s, "review.rejectAll", json!({}));
assert!(bottom(&s, 0).is_none());
}
#[test]
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
let mut s = typed("+--+------+");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\nA");
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell");
assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s));
// Enter after the deleted characters makes no second table.
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}}));
typ(&mut s, "\n");
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.rejectAll", json!({}));
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+"));
}
#[test]
fn border_line_autoformat() {
let s = typed("Above\n---\nBelow");
@@ -600,6 +879,155 @@ fn undo_right_after_a_border_line_brings_the_characters_back() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---");
}
/// The first paragraph's (left, first-line) indents as set on the paragraph itself.
fn indents(s: &Session) -> (Option<f32>, Option<f32>) {
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
(p.props.indent_left, p.props.indent_first)
}
#[test]
fn tab_and_backspace_at_a_paragraph_start_set_its_indents() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "Some text");
run(&mut s, "caret.docStart", json!({}));
// The first Tab indents the first line, the next one the whole paragraph.
typ(&mut s, "\t");
assert_eq!(indents(&s), (None, Some(36.0)));
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), Some(36.0)));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
// Backspace takes the first-line indent back, then the left indent, and stops there.
typ(&mut s, "\u{8}");
assert_eq!(indents(&s), (Some(36.0), Some(0.0)));
typ(&mut s, "\u{8}\u{8}");
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
// Each Tab is one undo step.
typ(&mut s, "\t");
run(&mut s, "edit.undo", json!({}));
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
// A left indent in characters goes back one tab stop too, instead of the paragraphs joining:
// six characters of 11 or 12 points are 66 or 72 points, two tab stops.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One\nTwo");
let second = Pos::body(1, 0);
s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0);
s.sel = crate::Selection::caret(second.clone());
typ(&mut s, "\u{8}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo");
let p = s.doc.para_at(&second).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0)));
// With formatting limited to the document's styles the option is off: Tab types a tab
// character at the start of the first line and of a later one, and Backspace at the start
// keeps the paragraph's left indent.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "Two");
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true}));
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo");
assert_eq!(indents(&s), (None, None));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0);
typ(&mut s, "\u{8}\u{8}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo");
assert_eq!(indents(&s), (Some(36.0), None));
// An empty paragraph takes a tab character.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "\tText");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText");
assert_eq!(indents(&s), (None, None));
// At the start of a list item numbered by its style, Tab types a tab character, as with the
// option off.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta");
assert_eq!(indents(&s), (None, None));
// With the option off (the default), Tab types a tab character.
let mut s = self::s();
typ(&mut s, "Some text");
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text");
assert_eq!(indents(&s), (None, None));
}
#[test]
fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, &"Words that run on past the end of one line. ".repeat(6));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.down", json!({}));
run(&mut s, "caret.home", json!({}));
assert!(s.sel.focus.off > 0, "the caret is on the second line");
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), None));
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
// After a line break too; inside a line Tab still types a tab character.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "Two");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), None));
run(&mut s, "caret.right", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two");
// Before a later line of a list item numbered by its style, Tab takes it down a level, as
// for a list item numbered directly.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "More");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props.numbering.map(|n| n.level), Some(1));
assert_eq!(indents(&s), (None, None));
// A list level past the last one, from a style or set directly, goes to the last level.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "More");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num;
s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
s.sel = crate::Selection::caret(Pos::body(1, 0));
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.down", json!({}));
run(&mut s, "caret.home", json!({}));
assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\"");
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
run(&mut s, "outline.demote", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
}
#[test]
fn plus_and_hyphen_line_becomes_a_table() {
let mut s = typed("+--+------+\nA");
@@ -621,6 +1049,118 @@ fn plus_and_hyphen_line_becomes_a_table() {
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
}
/// Body paragraphs as (text, paragraph style).
fn styled(s: &Session) -> Vec<(String, Option<String>)> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect()
}
fn with_heading_styles(keys: &str) -> Session {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
typ(&mut s, keys);
s
}
#[test]
fn two_enters_after_a_short_line_make_it_a_heading() {
let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string));
// No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in
// the paragraph after the heading.
for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] {
let s = with_heading_styles(keys);
let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default();
assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}");
}
// Five words is the most; one Enter only ends the line.
let s = with_heading_styles("One two three four five\n\n");
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
let s = with_heading_styles(keys);
assert!(
s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))),
"{keys:?}: {:?}",
styled(&s)
);
}
let mut q = self::s();
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
run(&mut q, "para.style", json!({"style": "Quote"}));
typ(&mut q, "Quoted\n\n");
assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote"));
// A list item numbered by the Normal style is not body text either.
let mut q = self::s();
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
if let Some(normal) = q.doc.styles.get_mut("Normal") {
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
}
typ(&mut q, "Item\n\n");
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
// Off by default.
let s = typed("Introduction\n\n");
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
}
#[test]
fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
let mut s = with_heading_styles("\tMethods\n\n");
assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]);
assert_eq!(s.sel.focus, Pos::body(1, 0));
run(&mut s, "edit.undo", json!({}));
// The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays.
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n");
assert!(styled(&s).iter().all(|(_, st)| st.is_none()));
assert_eq!(s.sel.focus, Pos::body(2, 0));
}
#[test]
fn a_tracked_heading_autoformat_can_be_rejected() {
// With Track Changes on, the tab is marked deleted and the style change is a formatting
// revision, so Reject All brings the line back as it was.
let mut s = with_heading_styles("\tMethods\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2")));
assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted");
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None)));
// A tab this author inserted is removed outright, and the style change is still tracked.
let mut s = with_heading_styles("Title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
typ(&mut s, "\t");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
// A line holding a tracked deletion is left alone.
let mut s = with_heading_styles("Old title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}}));
typ(&mut s, "\u{8}\u{8}\u{8}");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
}
#[test]
fn a_locked_heading_style_is_not_applied_as_you_type() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]}));
typ(&mut s, "Locked\n\n\tOpen\n\n");
let st = styled(&s);
assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}");
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
}
#[test]
fn arrow_bullets() {
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
@@ -799,6 +1339,20 @@ fn typing_then_selecting_then_typing_are_two_undo_steps() {
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
}
#[test]
fn backspace_at_the_start_of_the_document_is_not_an_edit() {
// #23: a key that changes nothing adds no undo step, marks no unsaved changes and
// leaves the redo stack alone.
let mut s = typed("alpha");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
s.dirty = false;
let depth = s.undo_depth();
typ(&mut s, "\u{8}");
assert_eq!(s.undo_depth(), depth, "no undo step");
assert!(!s.dirty, "no unsaved changes");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha");
}
#[test]
fn undoing_bold_at_the_caret_turns_bold_typing_off_again() {
// #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold.
@@ -2511,3 +3065,241 @@ fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before);
}
}
/// #4085: the registered shortcut toggles the last selected highlight, while explicit
/// colour commands remain idempotent setters for the palette, CLI and automation.
#[test]
#[cfg(not(target_os = "macos"))]
fn highlight_shortcut_toggles_the_last_colour() {
let mut s = typed("Alpha Beta");
run(&mut s, "select.all", json!({}));
let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone();
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), json!("FFFF00"));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
run(&mut s, "edit.undo", json!({}));
assert_eq!(colour(&mut s), json!("FFFF00"));
run(&mut s, "edit.redo", json!({}));
assert_eq!(colour(&mut s), serde_json::Value::Null);
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
assert_eq!(colour(&mut s), json!("0000FF"));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), json!("0000FF"));
run(&mut s, "format.highlight", json!({"color":"none"}));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta");
}
/// #4085: mixed selections take one colour; removing an inherited colour writes an
/// explicit No Color, and a collapsed caret toggles pending formatting for future typing.
#[test]
fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
let mut s = typed("Alpha Beta ");
run(&mut s, "select.text", json!({"text":"Alpha"}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "format.highlight", json!({}));
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
run(&mut s, "text.insert", json!({"text":"X"}));
run(&mut s, "select.text", json!({"text":"X"}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
let before = serde_json::to_value(&s.doc).unwrap();
assert!(s.run("format.highlight", &json!({"color":123})).is_err());
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
/// A differently coloured final paragraph must remain visible to the shortcut beyond
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
#[cfg(not(target_os = "macos"))]
#[test]
fn highlight_toggle_checks_past_the_resolved_run_limit() {
use wordcraft_doc::para::Run;
use wordcraft_doc::props::{CharProps, Highlight};
for count in [10_000, 10_001, 10_002] {
for inherited in [false, true] {
let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count)));
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count)
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
.collect();
if inherited {
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
} else {
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
}
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().mark.highlight = Some(Highlight::Blue);
let original = doc.clone();
let mut s = Session::new(doc);
s.last_highlight = Highlight::Blue;
run(&mut s, "select.all", json!({}));
press(&mut s, "Mod+Alt+H").unwrap();
assert!(
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)),
"count={count}, inherited={inherited}"
);
press(&mut s, "Mod+Alt+H").unwrap();
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None)));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap());
run(&mut s, "edit.redo", json!({}));
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
}
}
}
/// A command preview uses the real session's remembered colour without changing its
/// document, colour choice or undo history when the preview toggles highlighting off.
#[test]
fn highlight_preview_preserves_the_session_colour_and_document() {
use wordcraft_doc::props::Highlight;
let mut s = typed("Alpha");
run(&mut s, "select.all", json!({}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
let original = serde_json::to_value(&s.doc).unwrap();
let depth = s.undo_depth();
let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap();
assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
assert_eq!(s.last_highlight, Highlight::Blue);
assert_eq!(s.undo_depth(), depth);
}
/// Whole-cell column selections include paragraph marks, including empty cells and
/// inherited mark colours; adjacent unselected cells and undo/redo keep their formatting.
#[test]
fn highlight_toggle_checks_whole_cell_marks() {
use wordcraft_doc::props::Highlight;
use wordcraft_doc::{CharProps, Paragraph};
for inherited in [false, true] {
for empty in [false, true] {
let mut s = s();
run(&mut s, "insert.table", json!({"rows":2,"cols":2}));
let (table, _, _) = s.sel.focus.path.cell().unwrap();
let pos = |row, col| {
let mut path = table.clone();
path.0.extend([row, col, 0]);
Pos::new(StoryRef::Body, path, 0)
};
if inherited {
s.doc.styles.default_chr.highlight = Some(Highlight::Yellow);
}
for row in 0..2 {
for col in 0..2 {
let p = s.doc.para_mut(StoryRef::Body, &pos(row, col).path).unwrap();
let colour = if col == 0 { Highlight::Blue } else { Highlight::Red };
*p = Paragraph::with_text(
if empty && row == 1 && col == 0 { "" } else { "AB" },
CharProps { highlight: Some(colour), ..Default::default() },
);
p.mark.highlight = Some(colour);
if row == 1 && col == 0 {
p.mark.highlight = if inherited { None } else { Some(Highlight::Yellow) };
}
}
}
s.last_highlight = Highlight::Blue;
run(&mut s, "caret.set", json!({"pos":pos(0,0)}));
run(&mut s, "table.selectColumn", json!({}));
assert!(!s.cell_range().unwrap().stream);
let original = serde_json::to_value(&s.doc).unwrap();
run(&mut s, "format.highlight", json!({}));
for row in 0..2 {
let p = s.doc.para_at(&pos(row, 0)).unwrap();
assert_eq!(p.mark.highlight, Some(Highlight::Blue), "inherited={inherited}, empty={empty}");
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
}
run(&mut s, "format.highlight", json!({}));
for row in 0..2 {
let p = s.doc.para_at(&pos(row, 0)).unwrap();
assert_eq!(p.mark.highlight, Some(Highlight::None));
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
let other = s.doc.para_at(&pos(row, 1)).unwrap();
assert_eq!(other.mark.highlight, Some(Highlight::Red));
assert!(other.runs.iter().all(|r| r.props.highlight == Some(Highlight::Red)));
}
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.para_at(&pos(1, 0)).unwrap().mark.highlight, Some(Highlight::Blue));
}
}
}
/// Fully selected marks participate even when the selection has no text runs or an
/// empty paragraph between blue runs; repeated toggles and undo preserve these targets.
#[test]
fn highlight_toggle_checks_ordinary_paragraph_marks() {
use wordcraft_doc::props::Highlight;
for text in ["AB", "AB\n\nAB", "\n"] {
let mut doc = wordcraft_doc::Document::from_text(text);
doc.styles.default_chr.highlight = Some(Highlight::Blue);
let paths = doc.para_paths(StoryRef::Body);
for path in &paths {
let p = doc.para_mut(StoryRef::Body, path).unwrap();
p.mark.highlight = Some(Highlight::Blue);
for r in &mut p.runs {
r.props.highlight = Some(Highlight::Blue);
}
}
let mismatch = if text == "AB\n\nAB" { 1 } else { 0 };
doc.para_mut(StoryRef::Body, &paths[mismatch]).unwrap().mark.highlight = Some(Highlight::Yellow);
let original = serde_json::to_value(&doc).unwrap();
let mut s = Session::new(doc);
s.last_highlight = Highlight::Blue;
run(&mut s, "select.all", json!({}));
run(&mut s, "format.highlight", json!({}));
for path in &paths {
let p = s.doc.para(StoryRef::Body, path).unwrap();
assert_eq!(p.mark.highlight, Some(Highlight::Blue), "text={text:?}");
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
}
run(&mut s, "format.highlight", json!({}));
assert!(paths.iter().all(|p| s.doc.para(StoryRef::Body, p).unwrap().mark.highlight == Some(Highlight::None)));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
}
}
/// A partial text column excludes paragraph marks and text beyond its selected offsets,
/// so a different mark colour must not prevent clearing the selected blue characters.
#[test]
fn highlight_partial_column_ignores_unselected_marks() {
use wordcraft_doc::props::Highlight;
let mut doc = wordcraft_doc::Document::from_text("AB\nAB");
for path in doc.para_paths(StoryRef::Body) {
let p = doc.para_mut(StoryRef::Body, &path).unwrap();
p.mark.highlight = Some(Highlight::Yellow);
for r in &mut p.runs {
r.props.highlight = Some(Highlight::Blue);
}
}
let mut s = Session::new(doc);
s.last_highlight = Highlight::Blue;
run(&mut s, "select.column", json!({"anchor":Pos::body(0,0),"focus":Pos::body(1,1)}));
run(&mut s, "format.highlight", json!({}));
for row in 0..2 {
let p = s.doc.para_at(&Pos::body(row, 0)).unwrap();
assert_eq!(p.props_of_char(0).highlight, Some(Highlight::None));
assert_eq!(p.props_of_char(1).highlight, Some(Highlight::Blue));
assert_eq!(p.mark.highlight, Some(Highlight::Yellow));
}
}
+16
View File
@@ -76,6 +76,22 @@ fn moving_a_joined_shape_reroutes_its_connector() {
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
}
/// A command that changes nothing but reroutes a stale connector is still an edit: the
/// reroute must not be left without an undo step (#23).
#[test]
fn a_rerouted_connector_is_still_undoable() {
let mut s = joined();
select(&mut s, 5);
let before = connector_ends(&mut s);
// A nudge by nothing moves nothing, but the connector is stale and gets rerouted.
run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0}));
assert_ne!(connector_ends(&mut s), before, "the connector was rerouted");
assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own");
// One undo takes the reroute back.
run(&mut s, "edit.undo", json!({}));
assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back");
}
#[test]
fn dragging_a_connector_end_glues_it_to_the_nearest_site() {
let mut s = joined();
+6 -5
View File
@@ -1,10 +1,11 @@
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
//! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web
//! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size
//! budget. With `CRAFT_FONTS_REQUIRED` (releases), a native build also fails when the checkout
//! has no font for a CJK interface language (Japanese, Simplified Chinese), as an outdated pin
//! once did (#241). It only reads the local checkout: no network.
//! (wasm32) builds embed only the Georgian interface face (about 230 KB), to stay within the web
//! size budget; the Japanese one is 4.7 MB. With `CRAFT_FONTS_REQUIRED` (releases), a native
//! build also fails when the checkout has no font for a CJK interface language (Japanese,
//! Simplified Chinese), as an outdated pin once did (#241). It only reads the local checkout: no
//! network.
use std::fmt::Write as _;
use std::path::PathBuf;
@@ -55,7 +56,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec<String>), String> {
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") {
if wasm && !scripts.split(',').any(|s| s.trim() == "Geor") {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
+6 -2
View File
@@ -2172,11 +2172,15 @@ mod tests {
}
// A Thai font with OpenType tables, as the per-character fallback prefers.
assert!(f.has_layout_tables(), "{} shapes Thai", f.family);
// Without system fonts no bundled face covers Thai: the caller keeps the per-character path.
// Without system fonts and without craft-fonts' Thai face (built in since craft-fonts
// 3d9ab3a1) no bundled face covers Thai: the caller keeps the per-character path.
let bundled = FontDb::with_font_dirs(Vec::new());
bundled.set_system_fallback(false);
let latin = bundled.face(FALLBACK_FAMILY, "Regular");
assert!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_none());
// Expected from the embedded font list, not from the database under test.
let thai_bundled = crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Thai"));
assert_eq!(bundled.fallback_for('น', 0).is_some(), thai_bundled);
assert_eq!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_some(), thai_bundled);
}
#[test]
+5
View File
@@ -70,6 +70,11 @@ pub fn korean_fonts() -> impl Iterator<Item = &'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Kore"))
}
/// The craft-fonts Georgian (`"Geor"`) Regular face for the interface. Empty without craft-fonts.
pub fn georgian_fonts() -> impl Iterator<Item = &'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Geor") && f.style == "Regular")
}
/// The CJK interface faces, Regular styles only (the UI fakes no bold), with the Japanese UI,
/// Chinese, and Korean faces ordered for the selected interface language. Mixing the faces within
/// a line would mix glyph styles, so the first face should cover the interface language. Empty
+1 -1
View File
@@ -10,7 +10,7 @@ workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
image = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }
+15 -1
View File
@@ -2212,7 +2212,13 @@ impl<'a> Parser<'a> {
let mut chars = arg.chars();
let s = match chars.next() {
Some(b) => format!("{}{}", compose(b, mark), chars.as_str()),
None => mark.to_string(),
// An accent over nothing prints the accent alone: the spacing form of the mark.
None => match name {
"`" | "^" | "~" => name.to_string(),
"'" => "\u{B4}".to_string(),
"\"" => "\u{A8}".to_string(),
_ => mark.to_string(),
},
};
self.text(&s);
return;
@@ -2826,6 +2832,14 @@ mod tests {
paras(src).iter().map(|p| p.text()).collect()
}
#[test]
fn empty_accent_group_is_plain_character() {
let t = texts("\\begin{document}x\\^{}y \\~{}z 2\\^{}10 \\texttt{\\~{}/dir}\\end{document}");
assert_eq!(t, vec!["x^y ~z 2^10 ~/dir".to_string()]);
// The acute and the diaeresis alone are accents, not an apostrophe or a quotation mark.
assert_eq!(texts("\\begin{document}\\'{} \\\"{}\\end{document}"), vec!["\u{B4} \u{A8}".to_string()]);
}
#[test]
fn unclosed_begin_keeps_its_brace() {
// `env_name` used to consume the `{` before giving up on a missing `}`.
+4 -12
View File
@@ -1203,19 +1203,11 @@ pub fn image_px(data: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_dimensions().ok()
}
/// Largest allocation one decoded picture may make when it is re-encoded on export: the `image`
/// crate's default, stated here so the bound doesn't depend on a library default that may change.
const MAX_DECODE_ALLOC: u64 = 512 << 20;
/// Decode an encoded image with a bounded allocation, so a small file that declares huge
/// dimensions can't exhaust memory when a picture is re-encoded on export. `None` when it would
/// be too large or doesn't decode.
/// Decode an encoded image within the budget pictures are drawn within, so a small file that
/// declares huge dimensions can't exhaust memory when a picture is re-encoded on export. `None`
/// when it would be too large or doesn't decode.
pub fn decode_bounded(data: &[u8]) -> Option<image::DynamicImage> {
let mut reader = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_DECODE_ALLOC);
reader.limits(limits);
reader.decode().ok()
wordcraft_geom::picture::decode(data, wordcraft_geom::MAX_DECODED_BYTES).ok()
}
/// A picture from bytes with an optional display size (points); the natural size (96 dpi) is
+5
View File
@@ -8,5 +8,10 @@ description = "WordCraft geometry and units: points, twips, EMU, inches, centime
[lints]
workspace = true
[features]
# Picture decoding within a memory budget (`picture`), for the crates that decode pictures.
image = ["dep:image"]
[dependencies]
image = { workspace = true, optional = true }
serde = { workspace = true }
+2
View File
@@ -8,6 +8,8 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "image")]
pub mod picture;
mod region;
pub use region::Region;
mod pixels;
+479
View File
@@ -0,0 +1,479 @@
//! Decoding a picture within a memory budget. Every crate that decodes a document's or the
//! clipboard's pictures goes through [`probe`] (or [`probe_dib`]): the header gives the format and
//! size, and the decode is refused before anything is allocated when its peak would pass the
//! budget. The peak counts what a decoder holds beside its output (a progressive JPEG's
//! coefficients, #3644), and a picture whose decoder can't be held to a budget is not decoded at
//! all (a TIFF with JPEG strips, #3645).
use std::borrow::Cow;
use std::io::Cursor;
use image::{DynamicImage, ImageDecoder, ImageFormat};
/// Why a picture isn't decoded.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
/// Not a raster picture WordCraft decodes (a metafile, SVG or other bytes).
NotAPicture,
/// A kind of picture that is never decoded, because its decoder can't be held to a budget.
Unsupported(&'static str),
/// Decoding it would take more memory than the budget.
TooLarge { width: u32, height: u32 },
/// The header or the data is damaged.
Broken(String),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::NotAPicture => f.write_str("not a supported picture (PNG, JPEG, GIF, WebP, BMP or TIFF)"),
Refusal::Unsupported(what) => f.write_str(what),
Refusal::TooLarge { width, height } => write!(f, "the picture is too large ({width} × {height} pixels)"),
Refusal::Broken(why) => write!(f, "the picture is damaged ({why})"),
}
}
}
impl std::error::Error for Refusal {}
fn broken(e: image::ImageError) -> Refusal {
Refusal::Broken(e.to_string())
}
/// What a picture's header says: its format, its size and the memory decoding it takes. Reading
/// it allocates no pixels.
#[derive(Clone, Debug)]
pub struct Probe<'a> {
bytes: Cow<'a, [u8]>,
/// A DIB: a BMP without its file header, as the Windows clipboard holds one.
dib: bool,
pub format: ImageFormat,
pub width: u32,
pub height: u32,
peak: u64,
}
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
/// codes) and PackBits. The `tiff` crate decodes a JPEG strip (7) with a decoder of its own and
/// default limits, whatever size the TIFF declares, so such a TIFF is not decoded (#3645).
const TIFF_COMPRESSIONS: [u16; 6] = [1, 4, 5, 8, 32946, 32773];
/// Read a picture's header: PNG, JPEG, GIF, WebP, BMP or TIFF.
pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
let format = image::guess_format(bytes).map_err(|_| Refusal::NotAPicture)?;
let held = match format {
ImageFormat::Png | ImageFormat::Gif | ImageFormat::WebP | ImageFormat::Bmp => 0,
ImageFormat::Jpeg => jpeg_coefficients(bytes),
ImageFormat::Tiff => {
if !tiff_compression(bytes).is_some_and(|c| TIFF_COMPRESSIONS.contains(&c)) {
return Err(Refusal::Unsupported("TIFF pictures with JPEG or an unknown compression can't be read"));
}
0
}
_ => return Err(Refusal::NotAPicture),
};
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
let (width, height, peak) = measure(&decoder, held)?;
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
}
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
/// one (`CF_DIB`, `CF_DIBV5`). Its layout is first made the one the decoder reads (see
/// [`dib_layout`]).
pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
let dib = dib_layout(dib);
let (width, height, peak) = {
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
measure(&decoder, 0)?
};
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
}
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
let (width, height) = decoder.dimensions();
if width == 0 || height == 0 {
return Err(Refusal::Broken("it has no pixels".into()));
}
let native = u64::from(decoder.color_type().bytes_per_pixel());
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
Ok((width, height, peak))
}
impl Probe<'_> {
/// Bytes decoding takes at its peak.
pub fn peak_bytes(&self) -> u64 {
self.peak
}
/// Whether decoding fits `budget` bytes.
pub fn fits(&self, budget: u64) -> bool {
self.peak <= budget
}
/// Decode, refused before anything is allocated when the peak would pass `budget` bytes. The
/// decoder is held to the header's size and to `budget` as well.
pub fn decode(&self, budget: u64) -> Result<DynamicImage, Refusal> {
if !self.fits(budget) {
return Err(Refusal::TooLarge { width: self.width, height: self.height });
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(self.width);
limits.max_image_height = Some(self.height);
limits.max_alloc = Some(budget);
if self.dib {
let mut decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).map_err(broken)?;
decoder.set_limits(limits).map_err(broken)?;
return DynamicImage::from_decoder(decoder).map_err(broken);
}
let mut reader = image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format);
reader.limits(limits);
reader.decode().map_err(broken)
}
}
/// [`probe`], then [`Probe::decode`] within `budget` bytes.
pub fn decode(bytes: &[u8], budget: u64) -> Result<DynamicImage, Refusal> {
probe(bytes)?.decode(budget)
}
/// Bytes decoding a `width` × `height` picture holds at its peak: the decoder's output (`native`
/// bytes a pixel) beside what the decoder keeps while it decodes (`held`), then the output beside
/// its RGBA8 conversion, then that beside a premultiplied copy. None on overflow.
fn peak_bytes(width: u32, height: u32, native: u64, held: u64) -> Option<u64> {
let pixels = u64::from(width).checked_mul(u64::from(height))?;
let decoding = pixels.checked_mul(native)?.checked_add(held)?;
let converting = pixels.checked_mul(native.checked_add(4)?)?;
let premultiplying = pixels.checked_mul(8)?;
Some(decoding.max(converting).max(premultiplying))
}
/// Bytes of DCT coefficients a progressive JPEG's decoder keeps for the whole picture while it
/// reads the scans: zune-jpeg holds an `i16` per coefficient of every component, in whole MCUs.
/// 0 for a baseline JPEG, decoded an MCU row at a time. Each frame header before the first scan
/// counts and the largest wins; headers this can't follow are charged as the costliest frame.
fn jpeg_coefficients(bytes: &[u8]) -> u64 {
let mut at = 2;
let mut most = 0u64;
loop {
if bytes.get(at) != Some(&0xFF) {
return worst_coefficients(bytes).max(most);
}
while bytes.get(at) == Some(&0xFF) {
at += 1;
}
let Some(&code) = bytes.get(at) else { return worst_coefficients(bytes).max(most) };
at += 1;
match code {
// Markers without a length: start of image, TEM, restarts.
0xD8 | 0x01 | 0xD0..=0xD7 => continue,
// End of image, start of scan: every frame header is behind.
0xD9 | 0xDA => return most,
_ => {}
}
let len = match (bytes.get(at), bytes.get(at + 1)) {
(Some(&hi), Some(&lo)) => usize::from(u16::from_be_bytes([hi, lo])),
_ => 0,
};
let Some(segment) = bytes.get(at + 2..at + len).filter(|_| len >= 2) else { return worst_coefficients(bytes).max(most) };
// SOF2, SOF6, SOF10 and SOF14 are the progressive frames.
if matches!(code, 0xC2 | 0xC6 | 0xCA | 0xCE) {
let Some(held) = frame_coefficients(segment) else { return worst_coefficients(bytes).max(most) };
most = most.max(held);
}
at += len;
}
}
/// [`jpeg_coefficients`] of one progressive frame header (after its length): the components'
/// blocks per MCU (horizontal × vertical sampling) times the MCUs that cover the picture.
fn frame_coefficients(sof: &[u8]) -> Option<u64> {
let height = u64::from(u16::from_be_bytes([*sof.get(1)?, *sof.get(2)?]));
let width = u64::from(u16::from_be_bytes([*sof.get(3)?, *sof.get(4)?]));
let n = usize::from(*sof.get(5)?);
let sampling: Vec<(u64, u64)> =
sof.get(6..6 + 3 * n)?.as_chunks::<3>().0.iter().map(|&[_, s, _]| (u64::from(s >> 4).clamp(1, 4), u64::from(s & 15).clamp(1, 4))).collect();
let (h_max, v_max) = sampling.iter().fold((1, 1), |(h, v), &(a, b)| (h.max(a), v.max(b)));
let mcus = width.div_ceil(8 * h_max).checked_mul(height.div_ceil(8 * v_max))?;
sampling.iter().try_fold(0u64, |sum, &(h, v)| sum.checked_add(mcus.checked_mul(2 * 64 * h * v)?))
}
/// The coefficients of the costliest progressive frame of the size the image crate reads from
/// `bytes`: four components, each sampled 4 × 4, in MCUs of 32 pixels a side.
fn worst_coefficients(bytes: &[u8]) -> u64 {
let Ok((w, h)) = image::ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_dimensions() else { return 0 };
let mcus = u64::from(w).div_ceil(32) * u64::from(h).div_ceil(32);
mcus.saturating_mul(2 * 64 * 16 * 4)
}
/// The compression of a classic TIFF's first image (the one the image crate decodes): 1 when the
/// tag is missing. None when the header can't be read.
fn tiff_compression(bytes: &[u8]) -> Option<u16> {
let le = match bytes.get(..4)? {
b"II*\0" => true,
b"MM\0*" => false,
_ => return None,
};
let u16_at = |at: usize| -> Option<u16> {
let b = [*bytes.get(at)?, *bytes.get(at.checked_add(1)?)?];
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
};
let u32_at = |at: usize| -> Option<u32> {
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
};
let ifd = usize::try_from(u32_at(4)?).ok()?;
for i in 0..usize::from(u16_at(ifd)?) {
let entry = ifd.checked_add(2)?.checked_add(i.checked_mul(12)?)?;
if u16_at(entry)? != 259 {
continue;
}
if u32_at(entry.checked_add(4)?)? != 1 {
return None;
}
return match u16_at(entry.checked_add(2)?)? {
3 => u16_at(entry.checked_add(8)?),
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok(),
_ => None,
};
}
Some(1)
}
/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask
/// is set but whose compression says `BI_RGB` is marked `BI_BITFIELDS` (with the usual colour
/// masks when it gives none), so its alpha is kept as Windows programs mean it; arboard makes the
/// same change. Bit fields then need the colour masks after the header too, where Windows puts a
/// copy and the decoder skips them; a DIB without that copy (arboard writes one) gets it.
fn dib_layout(mut dib: Vec<u8>) -> Vec<u8> {
let u32_at = |d: &[u8], at: usize| d.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
let Some(size) = u32_at(&dib, 0).filter(|&s| s >= 108).and_then(|s| usize::try_from(s).ok()) else { return dib };
let put = |d: &mut Vec<u8>, at: usize, v: u32| {
if let Some(b) = d.get_mut(at..at + 4) {
b.copy_from_slice(&v.to_le_bytes());
}
};
if dib.get(14..16) == Some(&[32, 0]) && u32_at(&dib, 16) == Some(0) && u32_at(&dib, 52) == Some(0xFF00_0000) {
put(&mut dib, 16, 3);
if (40..52).step_by(4).all(|at| u32_at(&dib, at) == Some(0)) {
put(&mut dib, 40, 0x00FF_0000);
put(&mut dib, 44, 0x0000_FF00);
put(&mut dib, 48, 0x0000_00FF);
}
}
if u32_at(&dib, 16) == Some(3)
&& size <= dib.len()
&& let Some(masks) = dib.get(40..52).map(<[u8]>::to_vec)
&& dib.get(size..size + 12) != Some(masks.as_slice())
{
dib.splice(size..size, masks);
}
dib
}
#[cfg(test)]
mod tests {
use super::*;
fn encode(img: impl Into<DynamicImage>, format: ImageFormat) -> Vec<u8> {
let mut out = Vec::new();
img.into().write_to(&mut Cursor::new(&mut out), format).unwrap();
out
}
/// A PNG whose header declares `w` × `h` pixels, with no pixel data.
fn png_header(w: u32, h: u32) -> Vec<u8> {
let mut png = encode(image::RgbaImage::new(1, 1), ImageFormat::Png);
png[16..20].copy_from_slice(&w.to_be_bytes());
png[20..24].copy_from_slice(&h.to_be_bytes());
// The IHDR CRC covers the chunk type and data.
let crc = crc32(&png[12..29]);
png[29..33].copy_from_slice(&crc.to_be_bytes());
png
}
fn crc32(data: &[u8]) -> u32 {
let mut c = 0xFFFF_FFFFu32;
for &b in data {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
#[test]
fn small_pictures_decode_and_peaks_count_every_buffer() {
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
let p = probe(&png).unwrap();
assert_eq!((p.format, p.width, p.height), (ImageFormat::Png, 16, 8));
assert_eq!(p.peak_bytes(), 16 * 8 * 8, "RGBA8: the decode beside its premultiplied copy");
assert!(p.fits(1024) && !p.fits(1023));
assert_eq!(p.decode(1 << 20).unwrap().to_rgba8().get_pixel(3, 3).0, [1, 2, 3, 4]);
assert_eq!(p.decode(1023), Err(Refusal::TooLarge { width: 16, height: 8 }));
assert_eq!(peak_bytes(16, 8, 8, 0), Some(16 * 8 * 12), "RGBA16 beside its RGBA8 conversion");
assert_eq!(peak_bytes(16, 8, 3, 16 * 8 * 8), Some(16 * 8 * 11), "coefficients beside the RGB output");
assert_eq!(peak_bytes(u32::MAX, u32::MAX, 8, 0), None);
for bytes in [&b"not a picture"[..], b"", b"\x89PNG"] {
assert!(probe(bytes).is_err(), "{bytes:?}");
}
}
#[test]
fn a_huge_header_is_refused_before_decoding() {
let png = png_header(60_000, 60_000);
let p = probe(&png).unwrap();
assert_eq!((p.width, p.height), (60_000, 60_000));
assert!(!p.fits(crate::MAX_DECODED_BYTES));
assert_eq!(decode(&png, crate::MAX_DECODED_BYTES).unwrap_err(), Refusal::TooLarge { width: 60_000, height: 60_000 });
}
/// A baseline JPEG turned progressive in its headers (SOF2, a DC-only first scan), `w` × `h`
/// with its three components unsubsampled.
fn progressive_header(w: u16, h: u16) -> Vec<u8> {
let mut jpeg = encode(image::RgbImage::from_pixel(16, 16, image::Rgb([200, 30, 30])), ImageFormat::Jpeg);
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
jpeg[sof + 1] = 0xC2;
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
assert_eq!(jpeg[sof + 9], 3);
for c in 0..3 {
jpeg[sof + 11 + 3 * c] = 0x11;
}
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
let n = usize::from(jpeg[sos + 4]);
jpeg[sos + 6 + 2 * n] = 0;
jpeg
}
#[test]
fn progressive_jpeg_coefficients_are_charged() {
let jpeg = progressive_header(2048, 2048);
let p = probe(&jpeg).unwrap();
assert_eq!((p.width, p.height), (2048, 2048));
// RGB output (3 bytes a pixel) beside 2 bytes a coefficient of three full-size components:
// 9 bytes a pixel, more than the 8 two RGBA buffers take.
assert_eq!(p.peak_bytes(), 2048 * 2048 * 9);
assert!(!p.fits(2048 * 2048 * 8));
// Baseline: decoded an MCU row at a time, nothing held.
let baseline = encode(image::RgbImage::new(64, 64), ImageFormat::Jpeg);
assert_eq!(probe(&baseline).unwrap().peak_bytes(), 64 * 64 * 8);
}
#[test]
fn coefficients_follow_sampling_and_mcu_padding() {
// P, height, width, components (id, sampling, table).
let sof = |w: u16, h: u16, comps: &[u8]| {
let mut s = vec![8];
s.extend(h.to_be_bytes());
s.extend(w.to_be_bytes());
s.push((comps.len() / 3) as u8);
s.extend(comps);
s
};
// Four components, no subsampling: 2 bytes a coefficient, 8 a pixel.
assert_eq!(frame_coefficients(&sof(64, 64, &[1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 4, 0x11, 0])), Some(64 * 64 * 8));
// 4:2:0: MCUs of 16 × 16 holding 4 + 1 + 1 blocks; a 1-pixel-wide picture still takes one.
assert_eq!(frame_coefficients(&sof(1, 16, &[1, 0x22, 0, 2, 0x11, 0, 3, 0x11, 0])), Some(128 * 6));
let mut short = sof(1, 16, &[1, 0x22, 0, 2, 0x11, 0]);
short[5] = 3;
assert_eq!(frame_coefficients(&short), None, "three components declared, two there");
// A marker walk over SOI, an APP0 segment, SOF2, then the scan.
let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 4, 0, 0, 0xFF, 0xC2];
let f = sof(64, 64, &[1, 0x11, 0]);
jpeg.extend(((f.len() + 2) as u16).to_be_bytes());
jpeg.extend(&f);
jpeg.extend([0xFF, 0xDA]);
assert_eq!(jpeg_coefficients(&jpeg), 64 * 64 * 2);
}
/// A little-endian TIFF header whose first IFD holds just a compression tag.
fn tiff_with_compression(compression: u16) -> Vec<u8> {
let mut t = b"II*\0".to_vec();
t.extend(8u32.to_le_bytes());
t.extend(1u16.to_le_bytes());
t.extend(259u16.to_le_bytes());
t.extend(3u16.to_le_bytes());
t.extend(1u32.to_le_bytes());
t.extend(u32::from(compression).to_le_bytes());
t.extend(0u32.to_le_bytes());
t
}
#[test]
fn tiffs_with_jpeg_strips_are_never_decoded() {
assert_eq!(tiff_compression(&tiff_with_compression(7)), Some(7));
assert!(matches!(probe(&tiff_with_compression(7)), Err(Refusal::Unsupported(_))));
assert!(matches!(probe(&tiff_with_compression(6)), Err(Refusal::Unsupported(_))), "old-style JPEG");
let mut be = b"MM\0*\0\0\0\x08\0\x01\x01\x03\0\x03\0\0\0\x01\0\x07\0\0".to_vec();
assert_eq!(tiff_compression(&be), Some(7), "big-endian: the short is left-justified");
be.truncate(12);
assert_eq!(tiff_compression(&be), None, "cut short");
// An uncompressed TIFF reads as before.
let tiff = encode(image::RgbImage::from_pixel(5, 3, image::Rgb([9, 8, 7])), ImageFormat::Tiff);
assert_eq!(tiff_compression(&tiff), Some(1));
assert_eq!(decode(&tiff, 1 << 20).unwrap().to_rgb8().get_pixel(4, 2).0, [9, 8, 7]);
}
/// A BITMAPV5HEADER DIB of 32-bit pixels, bottom-up, with `alpha_mask`: `BI_RGB`, or with
/// `bitfields` the usual masks and `BI_BITFIELDS`, their copy after the header when `copy`.
fn dib(w: i32, h: i32, alpha_mask: u32, bitfields: bool, copy: bool, pixels: &[[u8; 4]]) -> Vec<u8> {
let mut d = vec![0u8; 124];
d[0..4].copy_from_slice(&124u32.to_le_bytes());
d[4..8].copy_from_slice(&w.to_le_bytes());
d[8..12].copy_from_slice(&h.to_le_bytes());
d[12..14].copy_from_slice(&1u16.to_le_bytes());
d[14..16].copy_from_slice(&32u16.to_le_bytes());
d[52..56].copy_from_slice(&alpha_mask.to_le_bytes());
if bitfields {
d[16..20].copy_from_slice(&3u32.to_le_bytes());
for (at, m) in [(40, 0x00FF_0000u32), (44, 0x0000_FF00), (48, 0x0000_00FF)] {
d[at..at + 4].copy_from_slice(&m.to_le_bytes());
}
if copy {
let masks = d[40..52].to_vec();
d.extend(masks);
}
}
for p in pixels {
d.extend(p);
}
d
}
#[test]
fn clipboard_dibs_keep_their_alpha_in_every_layout() {
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
let px = [[0, 0, 255, 128], [255, 0, 0, 255]];
// `BI_RGB` with an alpha mask (Windows programs); bit fields without the masks' copy
// after the header (arboard), and with it (Windows).
for (bitfields, copy) in [(false, false), (true, false), (true, true)] {
let p = probe_dib(dib(2, 1, 0xFF00_0000, bitfields, copy, &px)).unwrap();
assert_eq!((p.format, p.width, p.height), (ImageFormat::Bmp, 2, 1));
let rgba = p.decode(1 << 20).unwrap().to_rgba8();
assert_eq!(rgba.get_pixel(0, 0).0, [255, 0, 0, 128], "{bitfields} {copy}");
assert_eq!(rgba.get_pixel(1, 0).0, [0, 0, 255, 255], "{bitfields} {copy}");
}
// Without an alpha mask, `BI_RGB` pixels are opaque.
let p = probe_dib(dib(2, 1, 0, false, false, &px)).unwrap();
assert_eq!(p.decode(1 << 20).unwrap().to_rgba8().get_pixel(0, 0).0, [255, 0, 0, 255]);
}
#[test]
fn huge_or_damaged_clipboard_dibs_are_refused() {
let p = probe_dib(dib(60_000, 60_000, 0xFF00_0000, false, false, &[])).unwrap();
assert_eq!((p.width, p.height), (60_000, 60_000));
assert!(!p.fits(crate::MAX_DECODED_BYTES));
assert!(matches!(p.decode(crate::MAX_DECODED_BYTES), Err(Refusal::TooLarge { .. })));
assert!(probe_dib(vec![0; 8]).is_err());
// Cut short or lying about its header: no pixels come out, and nothing panics.
let mut short = dib(2, 1, 0xFF00_0000, true, false, &[]);
short.truncate(100);
let mut lying = dib(2, 1, 0xFF00_0000, true, false, &[[0; 4], [0; 4]]);
lying[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
for d in [short, lying] {
assert!(probe_dib(d).and_then(|p| p.decode(1 << 20)).is_err());
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ description = "WordCraft layout: shaping, line breaking, tabs, lists, tables, pa
workspace = true
[dependencies]
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-proof = { workspace = true }
+3 -8
View File
@@ -168,17 +168,12 @@ const MAX_PICTURE_SIDE: u32 = 8192;
/// in unit coordinates of the cropped picture. `None` for pictures without transparency (or
/// that don't decode): their rectangle is their outline.
pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let pic = wordcraft_geom::picture::probe(bytes).ok()?;
// Formats that can't be transparent aren't decoded.
if matches!(reader.format(), None | Some(image::ImageFormat::Jpeg)) {
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
return None;
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_PICTURE_SIDE);
limits.max_image_height = Some(MAX_PICTURE_SIDE);
limits.max_alloc = Some(512 << 20);
reader.limits(limits);
let img = reader.decode().ok()?;
let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?;
if !img.color().has_alpha() {
return None;
}
+55 -8
View File
@@ -197,6 +197,7 @@ pub enum Draw {
stroke_width: f32,
/// Alternating dash/space lengths in page points; empty means solid.
dash: Vec<f32>,
cap: wordcraft_doc::connector::LineCap,
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
effects: wordcraft_doc::effects::ShapeEffects,
/// A rounded rectangle's corner ([`wordcraft_doc::connector::ShapeExtra::corner`]).
@@ -206,7 +207,7 @@ pub enum Draw {
/// wide, in `color` at opacity `alpha`.
Ink { pts: Vec<(f32, f32)>, color: Rgb, width: f32, alpha: f32 },
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
Path { segs: Vec<PathSeg>, fill: Option<Paint>, stroke: Option<Rgb>, stroke_width: f32, dash: Vec<f32> },
Path { segs: Vec<PathSeg>, fill: Option<Paint>, stroke: Option<Rgb>, stroke_width: f32, dash: Vec<f32>, cap: wordcraft_doc::connector::LineCap },
/// An inline chart, diagram or described shape's draws, as one figure with its alt text (for
/// tagged PDF). `kind` is the graphic's, `None` for a shape (see [`crate::shape_figure_alt`]).
Figure { alt: String, kind: Option<GraphicKind>, draws: Vec<Draw> },
@@ -836,6 +837,10 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
}
}
/// Append display items for the half-open line range `l0..l1` of a laid-out paragraph.
/// `(x, y)` positions the paragraph's left origin and the first selected line's top; later lines
/// retain their layout offsets. Includes glyphs, inline objects, decorations and requested markup.
/// `down` keeps vertical East Asian glyphs upright before the caller turns the resulting draws.
fn lines(
doc: &Document,
story: StoryRef,
@@ -1324,6 +1329,7 @@ fn lines(
effects: Default::default(),
corner: None,
dash: Vec::new(),
cap: Default::default(),
});
}
// Ruby (Phonetic Guide) text over its base text, in runs of one style.
@@ -1549,10 +1555,18 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Dra
effects: Default::default(),
corner: None,
dash: Vec::new(),
cap: Default::default(),
}),
GraphicItem::Path { segs, fill, stroke, stroke_width } => {
let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy));
out.push(Draw::Path { segs, fill: fill.map(Paint::solid), stroke: *stroke, stroke_width: stroke_width * k, dash: Vec::new() })
out.push(Draw::Path {
segs,
fill: fill.map(Paint::solid),
stroke: *stroke,
stroke_width: stroke_width * k,
dash: Vec::new(),
cap: Default::default(),
})
}
GraphicItem::Text { rect: r, .. } => out.extend(text_glyphs(doc, inside(r), it, alpha, k)),
GraphicItem::Image { rect: r, media } => {
@@ -1630,7 +1644,17 @@ fn shape_draws(
return;
}
let Some(f) = freeform.filter(|_| kind == ShapeKind::Freeform) else {
out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner: extra.corner, dash: extra.dash.pattern(stroke_width) });
out.push(Draw::Shape {
rect,
kind,
fill,
stroke,
stroke_width,
effects,
corner: extra.corner,
dash: extra.dash.pattern(stroke_width),
cap: extra.cap,
});
return;
};
if f.is_ink() && opts.hide_ink {
@@ -1662,7 +1686,14 @@ fn shape_draws(
segs.push(PathSeg::Close);
}
let width = path_stroke_width(width);
out.push(Draw::Path { segs, fill: if closed { fill.clone() } else { None }, stroke, stroke_width: width, dash: extra.dash.pattern(width) });
out.push(Draw::Path {
segs,
fill: if closed { fill.clone() } else { None },
stroke,
stroke_width: width,
dash: extra.dash.pattern(width),
cap: extra.cap,
});
if !closed
&& extra.arrow_end
&& let Some(color) = stroke
@@ -1680,6 +1711,10 @@ fn path_stroke_width(width: f32) -> f32 {
if width.is_finite() && width > 0.0 { width.clamp(0.25, 200.0) } else { 0.75 }
}
/// Make a filled triangular arrowhead at the last point, in the points' coordinate space.
/// The last distinct segment supplies its direction and the bounded stroke width sets its size.
/// Returns `None` if there is no usable final segment. The triangle has no outline, so the path's
/// cap setting does not affect its appearance.
fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Option<Draw> {
let tip = *points.last()?;
let (prev_x, prev_y) = points.iter().rev().skip(1).copied().find(|p| *p != tip)?;
@@ -1695,7 +1730,7 @@ fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Optio
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
Some(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new() })
Some(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() })
}
/// A line or connector at `rect` (its unflipped frame: flips are the frame's turn), with its
@@ -1722,9 +1757,19 @@ fn line_draws(
})
.collect();
let width = path_stroke_width(stroke_width);
out.push(Draw::Path { segs, fill: None, stroke, stroke_width: width, dash: extra.dash.pattern(width) });
out.push(Draw::Path { segs, fill: None, stroke, stroke_width: width, dash: extra.dash.pattern(width), cap: extra.cap });
}
None => out.push(Draw::Shape { rect, kind, fill: None, stroke, stroke_width, effects, corner: None, dash: extra.dash.pattern(stroke_width) }),
None => out.push(Draw::Shape {
rect,
kind,
fill: None,
stroke,
stroke_width,
effects,
corner: None,
dash: extra.dash.pattern(stroke_width),
cap: extra.cap,
}),
}
let Some(color) = stroke else { return };
// Our line runs from the bottom-left corner to the top-right; connectors from the top-left
@@ -1741,7 +1786,7 @@ fn line_draws(
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
out.push(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new() });
out.push(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() });
}
}
@@ -1838,6 +1883,7 @@ pub fn check_box(frame: Rect, checked: bool, color: Rgb, alpha: f32, out: &mut V
effects: Default::default(),
corner: None,
dash: Vec::new(),
cap: Default::default(),
});
if checked {
out.extend([line(l, t, r, b, 0.48), line(r, t, l, b, 0.48)]);
@@ -1970,6 +2016,7 @@ fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw {
effects: Default::default(),
corner: None,
dash: Vec::new(),
cap: Default::default(),
}
}
+206 -11
View File
@@ -125,7 +125,8 @@ pub(crate) fn reference_link(instr: &str) -> Option<String> {
None
}
/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal…).
/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal,
/// ArabicDash, DollarText, Hex…). `{ PAGE \* ArabicDash }` shows "- 31 -".
pub fn format_switch(instr: &str) -> Option<NumFormat> {
let mut it = instr.split_whitespace();
while let Some(w) = it.next() {
@@ -141,6 +142,9 @@ pub fn format_switch(instr: &str) -> Option<NumFormat> {
"OrdText" | "ordtext" => NumFormat::OrdinalText,
"Ordinal" | "ordinal" => NumFormat::Ordinal,
"Arabic" | "arabic" => NumFormat::Decimal,
"ArabicDash" | "arabicdash" | "ARABICDASH" => NumFormat::NumberInDash,
"DollarText" | "dollartext" | "DOLLARTEXT" => NumFormat::DollarText,
"Hex" | "hex" | "HEX" => NumFormat::Hex,
// Chinese numerals: counting (一百零一), legal (壹佰零壹), digit by digit (一〇一).
v if v.eq_ignore_ascii_case("ChineseNum1") => NumFormat::ChineseCountingThousand,
v if v.eq_ignore_ascii_case("ChineseNum2") => NumFormat::ChineseLegalSimplified,
@@ -153,6 +157,120 @@ pub fn format_switch(instr: &str) -> Option<NumFormat> {
None
}
/// A field's capitalization switch (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CaseSwitch {
Upper,
Lower,
Caps,
FirstCap,
}
/// The capitalization switches in a field code, in the order they appear. Quoted text in the code
/// is not a switch.
fn case_switches(instr: &str) -> Vec<CaseSwitch> {
let mut chars = instr.chars().peekable();
let mut quoted = false;
let mut cases = Vec::new();
while let Some(c) = chars.next() {
match c {
'\\' if quoted => {
chars.next();
}
'"' => quoted = !quoted,
'\\' if chars.peek() == Some(&'*') => {
chars.next();
while chars.next_if(|c| c.is_whitespace()).is_some() {}
let mut word = String::new();
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '\\' && *c != '"') {
word.push(c);
}
let case = match word.to_ascii_lowercase().as_str() {
"upper" => CaseSwitch::Upper,
"lower" => CaseSwitch::Lower,
"caps" => CaseSwitch::Caps,
"firstcap" => CaseSwitch::FirstCap,
_ => continue,
};
cases.push(case);
}
_ => {}
}
}
cases
}
/// `text` with the field's capitalization switches applied in the order they appear, so
/// `\* Lower \* Caps` shows "WEEKLY REPORT" as "Weekly Report". `\* Upper` capitalizes every
/// letter, `\* Lower` makes every letter lowercase, `\* Caps` capitalizes the first letter of each
/// word, and `\* FirstCap` capitalizes the first letter of the first word. A word's first letter is
/// its first letter or digit, after any leading quote, bracket or other punctuation. Without a
/// switch, the function returns `text` unchanged.
pub fn apply_case_switch(instr: &str, text: String) -> String {
case_switches(instr).into_iter().fold(text, apply_case)
}
/// `text` with one capitalization switch applied (see [`apply_case_switch`]).
fn apply_case(text: String, case: CaseSwitch) -> String {
match case {
CaseSwitch::Upper => text.to_uppercase(),
CaseSwitch::Lower => text.to_lowercase(),
CaseSwitch::Caps | CaseSwitch::FirstCap => {
let mut out = String::with_capacity(text.len());
let mut word_start = true;
let mut done = false;
for c in text.chars() {
if c.is_whitespace() {
word_start = true;
out.push(c);
} else if word_start && !done && c.is_alphanumeric() {
out.extend(c.to_uppercase());
word_start = false;
done = case == CaseSwitch::FirstCap;
} else {
if c.is_alphanumeric() {
word_start = false;
}
out.push(c);
}
}
out
}
}
}
/// Formats `n` in `fmt` as a field shows it. Cardinal, ordinal and dollar text is lowercase
/// (`\* CardText` shows "twenty-one", `\* DollarText` shows "twenty-one and 00/100") and writes
/// numbers up to 999,999; a larger number keeps its digits. `\* Hex` writes uppercase hexadecimal
/// digits (`1CA`). The field's capitalization switches apply after that (`\* CardText \* Caps`
/// shows "Twenty-one").
pub fn field_number(instr: &str, fmt: NumFormat, n: u32) -> String {
let text = match fmt {
NumFormat::CardinalText | NumFormat::OrdinalText | NumFormat::DollarText if n > MAX_TEXT_NUMBER => n.to_string(),
NumFormat::CardinalText | NumFormat::OrdinalText => fmt.format(n).to_lowercase(),
NumFormat::DollarText => dollar_text(f64::from(n)).unwrap_or_else(|| n.to_string()),
NumFormat::Hex => format!("{n:X}"),
_ => fmt.format(n),
};
apply_case_switch(instr, text)
}
/// The largest number cardinal, ordinal and dollar text write in words.
const MAX_TEXT_NUMBER: u32 = 999_999;
/// `value` as `\* DollarText` writes it, before any capitalization switch: the whole part in
/// lowercase cardinal text, "and", and the first two decimals (rounded) over 100
/// (14.55 gives "fourteen and 55/100"). `None` for NaN, or for a value that rounds below 0 or past
/// 999,999.99.
pub fn dollar_text(value: f64) -> Option<String> {
let cents = (value * 100.0).round();
if !(0.0..=f64::from(MAX_TEXT_NUMBER) * 100.0 + 99.0).contains(&cents) {
return None;
}
let cents = cents as u32;
Some(format!("{} and {:02}/100", NumFormat::CardinalText.format(cents / 100).to_lowercase(), cents % 100))
}
/// The light grey behind a field shown as its code.
pub const FIELD_SHADING: wordcraft_doc::props::Rgb = wordcraft_doc::props::Rgb(0xD9, 0xD9, 0xD9);
@@ -171,16 +289,16 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S
let name = field_name(instr);
let fmt = format_switch(instr);
match name.as_str() {
"PAGE" => (fmt.unwrap_or(ctx.page_format).format(page), true),
"NUMPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.pages), true),
"SECTIONPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section_pages), true),
"SECTION" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section), true),
"PAGE" => (field_number(instr, format_switch(instr).unwrap_or(ctx.page_format), page), true),
"NUMPAGES" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.pages), true),
"SECTIONPAGES" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.section_pages), true),
"SECTION" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.section), true),
"PAGEREF" => match pageref_page(instr, ctx) {
Some((n, page_format)) => (fmt.unwrap_or(page_format).format(n), true),
Some((n, page_format)) => (field_number(instr, fmt.unwrap_or(page_format), n), true),
None => (result.to_string(), false),
},
"TITLE" if result.is_empty() => (ctx.title.to_string(), false),
"AUTHOR" if result.is_empty() => (ctx.author.to_string(), false),
"TITLE" if result.is_empty() => (apply_case_switch(instr, ctx.title.to_string()), false),
"AUTHOR" if result.is_empty() => (apply_case_switch(instr, ctx.author.to_string()), false),
"FILENAME" if result.is_empty() => {
let text = if has_switch(instr, 'p') && !ctx.filepath.is_empty() {
ctx.filepath.to_string()
@@ -189,12 +307,12 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S
} else {
"Document1".into()
};
(text, false)
(apply_case_switch(instr, text), false)
}
// ASK and SET only assign text to a bookmark: they never show a result (ECMA-376 §17.16.5).
"ASK" | "SET" => (String::new(), false),
"STYLEREF" => match &ctx.styleref {
Some((ix, at)) => (ix.eval(instr, *at), true),
Some((ix, at)) => (apply_case_switch(instr, ix.eval(instr, *at)), true),
None => (result.to_string(), false),
},
_ => (result.to_string(), false),
@@ -275,7 +393,7 @@ mod tests {
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
assert_eq!(field_text("PAGE \\*roman", "", &ctx).0, "iv");
assert_eq!(field_text("PAGE \\*roman\\*MERGEFORMAT", "", &ctx).0, "iv");
assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "Twelve");
assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "twelve");
assert_eq!(field_text("NUMPAGES \\* CHINESENUM1", "", &ctx).0, "十二");
assert_eq!(field_text("PAGE \\* ChineseNum2", "", &ctx).0, "肆");
assert_eq!(field_text("DATE \\@ \"M/d/yyyy\"", "1/2/2026", &ctx), ("1/2/2026".into(), false));
@@ -284,6 +402,83 @@ mod tests {
assert_eq!(field_code_text(""), "{ }");
}
/// Cardinal and ordinal text is lowercase unless a capitalization switch follows; `\* Upper`,
/// `\* Lower`, `\* Caps` and `\* FirstCap` apply to the page fields and the TITLE, AUTHOR and
/// FILENAME fallbacks. A switch inside quotes is text.
#[test]
fn capitalization_switches() {
let ctx = FieldCtx {
page: 21,
pages: 790,
title: "weekly report on sales".into(),
filename: "Weekly Sales Report.doc".into(),
..Default::default()
};
let text = |instr: &str| field_text(instr, "", &ctx).0;
assert_eq!(text(r"NUMPAGES \* CardText"), "seven hundred ninety");
assert_eq!(text(r"NUMPAGES \* CardText \* Caps"), "Seven Hundred Ninety");
assert_eq!(text(r"PAGE \* OrdText"), "twenty-first");
assert_eq!(text(r"PAGE \* OrdText \* FirstCap"), "Twenty-first");
assert_eq!(text(r"PAGE \* CardText \* Upper"), "TWENTY-ONE");
assert_eq!(text(r"PAGE \* roman \* upper"), "XXI");
assert_eq!(text(r"TITLE \* FirstCap \* MERGEFORMAT"), "Weekly report on sales");
assert_eq!(text(r"TITLE \*Caps"), "Weekly Report On Sales");
assert_eq!(text(r"FILENAME \* Lower"), "weekly sales report.doc");
assert_eq!(text(r"AUTHOR \* Upper"), "");
for (title, caps, first_cap) in [
("\"weekly report\"", "\"Weekly Report\"", "\"Weekly report\""),
("(weekly) report", "(Weekly) Report", "(Weekly) report"),
(" q3 - final", " Q3 - Final", " Q3 - final"),
] {
let ctx = FieldCtx { title: title.into(), ..Default::default() };
assert_eq!(field_text(r"TITLE \* Caps", "", &ctx).0, caps);
assert_eq!(field_text(r"TITLE \* FirstCap", "", &ctx).0, first_cap);
}
assert_eq!(field_text(r#"TITLE "x \* Upper""#, "", &ctx).0, "weekly report on sales");
assert_eq!(field_text(r"REF mark \* Upper", "stored", &ctx).0, "stored", "a stored result shows as it is");
}
/// `\* DollarText` writes lowercase cardinal text with "and 00/100", `\* Hex` uppercase
/// hexadecimal digits, and `\* ArabicDash` the number between hyphens. Words
/// stop at 999,999; a larger number keeps its digits.
#[test]
fn dollar_text_hex_and_arabic_dash_switches() {
let ctx = FieldCtx { page: 31, pages: 458, section: 790, ..Default::default() };
let text = |instr: &str| field_text(instr, "", &ctx).0;
assert_eq!(text(r"PAGE \* ArabicDash"), "- 31 -");
assert_eq!(text(r"PAGE \*arabicdash \* MERGEFORMAT"), "- 31 -");
assert_eq!(text(r"NUMPAGES \* ArabicDash"), "- 458 -", "any number field reads ArabicDash");
assert_eq!(text(r"NUMPAGES \* ArabicDash \* roman"), "- 458 -", "the first format switch counts");
assert_eq!(text(r"NUMPAGES \* Hex"), "1CA");
assert_eq!(text(r"NUMPAGES \* hex \* Lower"), "1ca");
assert_eq!(text(r"PAGE \* Hex"), "1F");
assert_eq!(text(r"SECTION \* DollarText"), "seven hundred ninety and 00/100");
assert_eq!(text(r"SECTION \* DollarText \* Upper"), "SEVEN HUNDRED NINETY AND 00/100");
assert_eq!(text(r"PAGE \* DollarText \* FirstCap"), "Thirty-one and 00/100");
assert_eq!(dollar_text(9.20 + 5.35).as_deref(), Some("fourteen and 55/100"));
assert_eq!(dollar_text(0.999).as_deref(), Some("one and 00/100"), "the decimals round");
assert_eq!(dollar_text(999_999.99).as_deref(), Some("nine hundred ninety-nine thousand nine hundred ninety-nine and 99/100"));
assert_eq!(dollar_text(999_999.995), None);
assert_eq!(dollar_text(-1.0), None);
assert_eq!(dollar_text(-0.004).as_deref(), Some("zero and 00/100"), "a value that rounds to 0 is not below 0");
assert_eq!(dollar_text(f64::NAN), None);
for fmt in [NumFormat::CardinalText, NumFormat::OrdinalText, NumFormat::DollarText] {
assert_eq!(field_number(r"SEQ x \* Upper", fmt, 1_000_140), "1000140", "{fmt:?}");
}
assert_eq!(field_number("SEQ x", NumFormat::CardinalText, 999_999), "nine hundred ninety-nine thousand nine hundred ninety-nine");
}
/// Several capitalization switches apply in the order they appear.
#[test]
fn capitalization_switches_apply_in_order() {
let ctx = FieldCtx { title: "WEEKLY REPORT".into(), ..Default::default() };
let text = |instr: &str| field_text(instr, "", &ctx).0;
assert_eq!(text(r"TITLE \* Lower \* Caps"), "Weekly Report");
assert_eq!(text(r"TITLE \* Lower \* FirstCap"), "Weekly report");
assert_eq!(text(r"TITLE \* Caps \* Lower"), "weekly report");
assert_eq!(text(r"TITLE \* Lower \* MERGEFORMAT \* Upper"), "WEEKLY REPORT");
}
/// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.
#[test]
fn ask_and_set_show_nothing() {
+55 -24
View File
@@ -707,6 +707,11 @@ enum Pass {
/// Walk state while paginating.
struct Ctx<'a> {
doc: &'a Document,
/// The body's sections ([`Document::sections`]) for one pass, with the block index each
/// starts after: header and footer wrap work asks for them per page, and rebuilding the
/// list walked the whole body every time, so a long document paid a body-length scan per
/// page. The document does not change during a pass.
sections: Vec<(usize, &'a SectionProps)>,
cache: &'a mut LayoutCache,
opts: &'a LayoutOptions,
counters: Counters,
@@ -1281,6 +1286,9 @@ fn layout_box(
} else if i > 0 && before > 0.0 && prev_after > 0.0 && !ctx.doc.settings.sum_paragraph_spacing {
before = (before - prev_after).max(0.0);
}
// Paragraph-relative floats start above the effective space before, as in the
// body path. Keep text and wrap exclusions relative to the text's actual top.
let para_top = y;
y += if i == 0 { before } else { before.max(0.0) };
// Floating objects anchored here: place them, then wrap the text around them.
// One left out of the layout (hidden, or deleted in the final text) takes no room.
@@ -1295,10 +1303,13 @@ fn layout_box(
}
if let Some(pin) = ctx.pin.as_ref().filter(|pin| in_cell && pin.story == story && float.outside_cell && page_anchored(float)) {
let page = PageFrame { sect: &pin.sect, origin: (0.0, 0.0), number: pin.number, mirror: pin.mirror };
pinned.insert(oi, float_rect(Some(page), (0.0, width), y, w, h, float));
pinned.insert(oi, float_rect(Some(page), (0.0, width), para_top, w, h, float));
continue;
}
let r = float_rect(frame, (0.0, width), y, w, h, float);
// 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 outline = ctx.wrap_outline(o);
excl.extend(wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())));
floats.insert(oi, r);
@@ -1324,7 +1335,7 @@ fn layout_box(
prev_end = push_para(&mut items, story, &path, &pl, 0, pl.lines.len(), 0.0, y + lead, width, edges);
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
if !p.objects.is_empty() {
let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
let (back, front) = place_objects(ctx, story, &path, p, &pl, (0, pl.lines.len()), (0.0, y + lead), &at, &floats, depth);
for placed in back.iter().chain(front.iter()) {
if let Placed::Object { rect, spin, wrap, .. } = placed
@@ -1365,11 +1376,11 @@ fn layout_box(
Some(f) => {
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
let (r, area) = box_float_table_rect(frame, width, y, &tl, &f, legacy, &float_tables);
excl.push((area, false, None, WrapText::BothSides));
excl.push((area, false, None, WrapText::BothSides, true));
float_tables.push(area);
table_areas.push(area);
if f.v_rel == Anchor::Page {
page_table_wraps.push((area, false, None, WrapText::BothSides));
page_table_wraps.push((area, false, None, WrapText::BothSides, true));
}
(r.x, r.y)
}
@@ -2308,6 +2319,7 @@ fn layout_pass(
});
let mut ctx = Ctx {
doc,
sections: doc.sections(),
cache,
opts,
hide_deleted: opts.hides_deleted(StoryRef::Body),
@@ -3104,8 +3116,9 @@ fn align_in((start, extent): (f32, f32), offset: f32, size: f32, align: Option<F
}
/// An area text keeps clear of: its bounds, whether text may only go above and below it, and
/// (Tight and Through wrapping) the outline text follows inside it, and the sides text may go on.
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>, WrapText);
/// (Tight and Through wrapping) the outline text follows inside it, the sides text may go on,
/// and whether it comes from a floating table (inclusive line-bottom contact, #3977).
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>, WrapText, bool);
/// Wrap areas a page starts with (#2048): by page index, the floating objects (block, object
/// index) placed above the top of their anchor paragraph, with text before it on the page, and
@@ -3118,14 +3131,15 @@ type EarlyFloats = HashMap<usize, Vec<(usize, usize, WrapArea)>>;
/// out before it ([`settle_early`]).
const MAX_REWRAP_PASSES: usize = 3;
/// Whether the text kept clear of `seed` is kept clear of `area` too: the same wrap side, `area`
/// inside `seed`, and no outline on `seed` other than `area`'s own in the same place.
/// Whether the text kept clear of `seed` is kept clear of `area` too: the same table-contact
/// rule and wrap side, `area` inside `seed`, and no outline on `seed` other than `area`'s own
/// in the same place.
fn covers(seed: &WrapArea, area: &WrapArea) -> bool {
let (s, r) = (seed.0, area.0);
let inside = r.x >= s.x - 0.5 && r.y >= s.y - 0.5 && r.right() <= s.right() + 0.5 && r.bottom() <= s.bottom() + 0.5;
let same_place = (r.x - s.x).abs() < 0.5 && (r.y - s.y).abs() < 0.5 && (r.w - s.w).abs() < 0.5 && (r.h - s.h).abs() < 0.5;
let outline = seed.2.is_none() || (same_place && seed.2 == area.2);
(seed.3 == area.3 || seed.1) && (seed.1 || !area.1) && inside && outline
seed.4 == area.4 && (seed.3 == area.3 || seed.1) && (seed.1 || !area.1) && inside && outline
}
/// In a pass no other follows ([`MAX_REWRAP_PASSES`]), a float at `r` with wrap area `area`
@@ -3176,7 +3190,7 @@ fn merge_early(early: &mut EarlyFloats, found: EarlyFloats) {
match have.iter_mut().find(|(eb, ek, _)| (*eb, *ek) == (b, k)) {
Some((_, _, seed)) if !covers(seed, &area) => {
let beside = seed.3 == area.3 && !seed.1 && !area.1;
*seed = (seed.0.union(&area.0), !beside, None, area.3);
*seed = (seed.0.union(&area.0), !beside, None, area.3, area.4);
}
Some(_) => {}
None => have.push((b, k, area)),
@@ -3196,19 +3210,19 @@ fn wrap_area(r: Rect, float: &Float, outline: Option<&[(f32, f32)]>) -> Option<W
if matches!(float.wrap, Wrap::Tight | Wrap::Through)
&& let Some((area, c)) = outline.and_then(|o| contour::contour_area(r, float, o))
{
return Some((area, false, Some(Arc::new(c)), float.wrap_text));
return Some((area, false, Some(Arc::new(c)), float.wrap_text, false));
}
let d = |v: f32| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 };
let (side, top, bottom) = (d(float.dist), d(float.dist_top), d(float.dist_bottom));
// A rotated object keeps text clear of its rotated bounds.
let r = float.spin().bounds(r);
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None, float.wrap_text))
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None, float.wrap_text, false))
}
/// Wrap areas relative to a paragraph whose column starts at `x0` and whose top is `y0`.
fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec<para::Exclusion> {
excl.iter()
.map(|(r, tb, c, side)| para::Exclusion {
.map(|(r, tb, c, side, table)| para::Exclusion {
top: r.y - y0,
bottom: r.bottom() - y0,
left: r.x - x0,
@@ -3216,6 +3230,7 @@ fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec<para::Exclusion> {
top_bottom: *tb,
contour: c.clone(),
side: *side,
table: *table,
})
.filter(|e| e.bottom > 0.0)
.collect()
@@ -3398,12 +3413,11 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapArea> {
}
ctx.footer_wraps.insert(key, Vec::new());
let doc = ctx.doc;
let sections = doc.sections();
let Some((_, sect)) = sections.get(page.section) else { return Vec::new() };
let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() };
if sect.text_direction.is_turned() {
return Vec::new();
}
let set = linked(&sections, page.section, |s| s.footers);
let set = linked(&ctx.sections, page.section, |s| s.footers);
let id = if sect.title_page && first {
set.first
} else if doc.settings.even_odd_headers && page.number.is_multiple_of(2) {
@@ -3415,7 +3429,7 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapArea> {
let Some(part) = doc.parts.get(&id) else { return Vec::new() };
let blocks = part.blocks.clone();
let x = sect.margin_left + sect.gutter;
let base = defining_width(&sections, page.section, |s| s.footers, Some(id));
let base = defining_width(&ctx.sections, page.section, |s| s.footers, Some(id));
let counters = std::mem::take(&mut ctx.counters);
let boxes = std::mem::take(&mut ctx.boxes);
let chains = std::mem::take(&mut ctx.chains);
@@ -3909,7 +3923,23 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
- new_notes.iter().map(|n| n.h).sum::<f32>()
- if pb.notes.is_empty() && !new_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 }
- extra;
let bottom = pb.y + lead + (l.top + l.height - first.top);
// Multiple spacing above single puts its extra leading below the text (see
// `baseline_offset`), and Word lets that leading of a page's last line run into the
// bottom margin: only the text has to fit (observed: a double-spaced 12pt line stays on
// the page while its text ends above the margin, about half a line lower than when the
// whole line must fit). Only the bottom margin takes that leading: a footer taller than
// the margin, or footnotes, keep the whole line clear of them.
let at_margin = pb.bottom >= pb.sect.page_h - pb.sect.margin_bottom - 0.01;
let fit_h = match pl.rp.line_spacing {
wordcraft_doc::props::LineSpacing::Multiple(m)
if m > 1.0 && at_margin && pb.notes.is_empty() && new_notes.is_empty() && line_notes.is_empty() =>
{
// `baseline` is from the paragraph's top; the text ends a descent below it.
(l.baseline - l.top + l.text_desc).clamp(0.0, l.height)
}
_ => l.height,
};
let bottom = pb.y + lead + (l.top + fit_h - first.top);
// On a page with footnotes the paragraph's last line keeps its space after clear of
// the separator (Word): if the space doesn't fit, the line goes to the next page (and,
// with widow control, one more with it). Without notes the space after may run past
@@ -4517,12 +4547,11 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
if let Some(areas) = ctx.header_tables.get(&key) {
return areas.clone();
}
let sections = ctx.doc.sections();
let Some((_, sect)) = sections.get(page.section) else { return Vec::new() };
let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() };
if sect.text_direction.is_turned() {
return Vec::new();
}
let set = linked(&sections, page.section, |s| s.headers);
let set = linked(&ctx.sections, page.section, |s| s.headers);
let id = if sect.title_page && first {
set.first
} else if ctx.doc.settings.even_odd_headers && page.number.is_multiple_of(2) {
@@ -4535,7 +4564,7 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
let blocks = part.blocks.clone();
let x = sect.margin_left + sect.gutter;
let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror };
let base = defining_width(&sections, page.section, |s| s.headers, Some(id));
let base = defining_width(&ctx.sections, page.section, |s| s.headers, Some(id));
let counters = std::mem::take(&mut ctx.counters);
let boxes = std::mem::take(&mut ctx.boxes);
let chains = std::mem::take(&mut ctx.chains);
@@ -4606,7 +4635,7 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa
pg.float_tables.push(Path::top(block));
}
let area = r.inset(-dl, -dt, -dr, -db);
pb.excl.push((area, false, None, WrapText::BothSides));
pb.excl.push((area, false, None, WrapText::BothSides, true));
pb.float_tables.push(area);
return;
}
@@ -4923,6 +4952,8 @@ mod tests_pages;
#[cfg(test)]
mod tests_styleref;
#[cfg(test)]
mod tests_table_contact;
#[cfg(test)]
mod tests_textbox;
#[cfg(test)]
mod tests_vertical;
+17 -4
View File
@@ -588,6 +588,16 @@ pub struct Exclusion {
pub contour: Option<Arc<crate::contour::Contour>>,
/// The sides of the object text may go on (Square, Tight, Through).
pub side: WrapText,
/// A floating table counts a line touching its top as overlapping; shapes do not (#3977).
pub table: bool,
}
impl Exclusion {
/// Whether this area keeps text out of the row, including exact top contact for tables.
fn intersects_row(&self, top: f32, h: f32, table_h: f32, lo: f32, hi: f32) -> bool {
let reaches = if self.table { self.top <= top + table_h } else { self.top < top + h };
self.bottom > top && reaches && self.right > lo && self.left < hi
}
}
struct Builder<'a> {
@@ -2093,6 +2103,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
.map(|st| (st.ascent + st.descent) * if let LineSpacing::Multiple(m) = rp.line_spacing { m } else { 1.0 })
.unwrap_or(14.0)
.max(1.0);
// Exact spacing fixes the table-contact boundary even when the mark has other metrics.
// Keep the existing estimate for shapes and for non-exact spacing.
let table_h = if let LineSpacing::Exactly(h) = rp.line_spacing { h } else { est_h };
// The rest of the current row: spans on the far side of floating objects, left to right.
let mut row_rest: VecDeque<(f32, f32)> = VecDeque::new();
let mut row_first = 0usize;
@@ -2119,7 +2132,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
{
lo = first_left.max(rp.indent_left) + dw;
}
match row_spans(exclusions, top, est_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) {
match row_spans(exclusions, top, est_h, table_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) {
Ok((Some((a, b)), rest)) => {
narrowed = !rest.is_empty() || a > lo || b < base_right;
row_rest = rest;
@@ -2403,7 +2416,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
// Nothing on this row: go below the first object that ends, as when no span is wide enough.
exclusions
.iter()
.filter(|e| e.bottom > top && e.top < top + est_h && e.right > row_lo && e.left < base_right)
.filter(|e| e.intersects_row(top, est_h, table_h, row_lo, base_right))
.map(|e| e.bottom)
.reduce(f32::min)
.unwrap_or(top + est_h)
@@ -2803,8 +2816,8 @@ fn baseline_offset(spacing: LineSpacing, asc: f32, height: f32, desc: f32) -> f3
/// The spans of a row at `top` (about `h` tall) between `lo` and `hi` that text can use around
/// the exclusions, left to right (Square wrapping uses both sides of an object). `Err(y)`: none
/// here, try again at `y` (below what's in the way).
fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, lo: f32, hi: f32) -> Result<VecDeque<(f32, f32)>, f32> {
let here: Vec<&Exclusion> = exclusions.iter().filter(|e| e.bottom > top && e.top < top + h && e.right > lo && e.left < hi).collect();
fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, table_h: f32, lo: f32, hi: f32) -> Result<VecDeque<(f32, f32)>, f32> {
let here: Vec<&Exclusion> = exclusions.iter().filter(|e| e.intersects_row(top, h, table_h, lo, hi)).collect();
if let Some(below) = here.iter().filter(|e| e.top_bottom).map(|e| e.bottom).reduce(f32::max) {
return Err(below);
}
+110 -3
View File
@@ -868,6 +868,26 @@ fn hidden_separators_with_automatic_hyphenation_lay_out_in_linear_time() {
assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
/// n one-line paragraphs, each starting a page.
fn page_break_paragraphs(n: usize) -> Document {
let mut d = Document::from_text(&"hello\n".repeat(n));
for i in 0..n {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(true);
}
d
}
/// A document of many paragraphs lays out in linear time. Every page asks the layout for the
/// section properties (for the wrap areas of the headers and footers), and that used to rebuild
/// the section list by walking the whole body, so a document of n paragraphs paid n body-length
/// scans: a two-megabyte file took half a minute before the first page was drawn.
#[test]
fn a_document_of_many_paragraphs_lays_out_in_linear_time() {
let (t1, tk, ratio) = layout_time_ratio(page_break_paragraphs, 2_500, &LayoutOptions::default());
eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}");
assert!(ratio < 24.0, "8x the paragraphs took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
#[test]
fn many_hidden_float_anchors_lay_out_in_linear_time() {
crate::test_fonts();
@@ -5150,6 +5170,79 @@ 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]);
}
/// Paragraph anchors exclude space-before; line and frameless page/margin anchors retain it.
#[test]
fn box_float_origins_respect_vertical_anchor_with_space_before() {
use wordcraft_doc::{
PartKind,
para::{Anchor, Float, Wrap},
};
for context in ["header", "footer", "cell", "textbox"] {
for anchor in [
Anchor::Paragraph,
Anchor::Line,
Anchor::Page,
Anchor::Margin,
Anchor::TopMargin,
Anchor::BottomMargin,
Anchor::InsideMargin,
Anchor::OutsideMargin,
] {
if matches!(context, "header" | "footer") && !matches!(anchor, Anchor::Paragraph | Anchor::Line) {
continue;
}
let gap = |before| {
let mut d = Document::from_text("Body");
let mut p = wordcraft_doc::Paragraph::with_text("Anchor", Default::default());
p.props.space_before = Some(before);
let float = Float { wrap: Wrap::BehindText, v_rel: anchor, y: 7.0, ..Default::default() };
p.insert_object(0, rect_shape(37.0, 9.0, float), &Default::default()).unwrap();
let story = match context {
"header" | "footer" => {
let kind = if context == "header" { PartKind::Header } else { PartKind::Footer };
let id = d.add_part(kind, vec![wordcraft_doc::para_block(p)]);
if context == "header" {
d.last_section.headers.default = Some(id);
} else {
d.last_section.footers.default = Some(id);
}
StoryRef::Part(id)
}
"cell" => {
let mut t = Table::new(1, 1, 468.0);
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();
StoryRef::Body
}
_ => {
let id = text_box(&mut d, 0, "", 200.0, 100.0, page_float(100.0, 200.0));
d.parts.get_mut(&id).unwrap().blocks = vec![wordcraft_doc::para_block(p)];
StoryRef::Part(id)
}
};
let l = lay(&d);
let page = &l.pages[0];
let items = match context {
"header" => &page.header,
"footer" => &page.footer,
_ => &page.items,
};
let y = items
.iter()
.find_map(|i| match i {
Placed::Lines { story: s, y, path, .. } if *s == story && (context != "cell" || path.0.len() > 1) => Some(*y),
_ => None,
})
.unwrap();
let r = shapes(items).into_iter().find(|r| (r.w - 37.0).abs() < 0.01).unwrap();
r.y - y
};
let expected = if anchor == Anchor::Paragraph { -24.0 } else { 0.0 };
assert!((gap(24.0) - gap(0.0) - expected).abs() < 0.01, "{context} {anchor:?}: float/text gap must change by {expected}");
}
}
}
/// Inside a text box whose shape style gives its text a colour (`a:fontRef`, typically white on
/// the accent fill), automatic text takes that colour instead of black; text with a colour of
/// its own keeps it (Word, observed with synthetic documents).
@@ -9132,15 +9225,29 @@ fn space_after_stays_clear_of_the_footnote_separator() {
fn space_after_may_overflow_the_bottom_margin_without_notes() {
crate::test_fonts();
// Without footnotes nothing changes: the last line only has to fit, its space after may run
// past the bottom margin (Word).
// past the bottom margin (Word). "Fit" means its text: the leading multiple spacing adds below
// the text (this paragraph's 1.15) may run past the margin too (observed in Word at 1.15, 1.5
// and double spacing; see `a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits`).
let mut tight = 0;
for before in 0..=300 {
let (d, t, _) = space_after_doc(before as f32, false);
let l = lay(&d);
let bottom_margin = d.last_section.page_h - d.last_section.margin_bottom;
let (pieces, _) = para_pieces(&l, t);
let &(_, _, _, bottom) = pieces.last().unwrap();
assert!(bottom <= bottom_margin + 0.01, "before {before}: {bottom}");
let &(page, _, l1, bottom) = pieces.last().unwrap();
let leading = l.pages[page]
.items
.iter()
.find_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, .. } if path.0 == [t as u32] => {
para.lines.get(l1 - 1).map(|ln| ln.height - (ln.baseline - ln.top + ln.text_desc))
}
_ => None,
})
.unwrap()
.max(0.0);
let bottom = bottom - leading;
assert!(bottom <= bottom_margin + 0.01, "before {before}: the last line's text ends at {bottom}");
if bottom > bottom_margin - 6.0 {
tight += 1;
}
+43
View File
@@ -583,3 +583,46 @@ fn a_roman_page_number_in_the_footer_stays_roman_with_the_page_count_in_the_body
assert_eq!(glyphs, want.chars().count(), "page {}: the footer should show {want}", p.number);
}
}
#[test]
fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
// Multiple spacing puts its extra leading below the text, and Word lets that leading of a
// page's last line run into the bottom margin: a multiple-spaced line stays on the page while
// its text ends above the margin (observed in Word at 1.15, 1.5 and double spacing: the page
// holds one more line than when the whole line box must fit, for shifts up to about the
// leading). Each shift moves the text down; on every page the last line's text ends within
// the margin, and the next line's would not.
use wordcraft_doc::props::LineSpacing;
for multiple in [1.15, 1.5, 2.0] {
let mut past_margin = 0;
for shift in (0..28).step_by(2) {
let mut d = Document::from_text(&"Several words wrap onto many lines of a long paragraph. ".repeat(60));
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(Arc::make_mut) {
p.props.line_spacing = Some(LineSpacing::Multiple(multiple));
p.props.space_before = Some(shift as f32);
p.props.widow_control = Some(false);
}
let bottom = d.last_section.page_h - d.last_section.margin_bottom;
let l = lay(&d);
assert!(l.pages.len() >= 2, "{multiple} shift {shift}: the paragraph should run onto a second page");
let Some(Placed::Lines { y, para, l0, l1, .. }) =
l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { story: StoryRef::Body, .. }))
else {
panic!("{multiple} shift {shift}: no body lines on page 1")
};
let (first, last, next) = (&para.lines[*l0], &para.lines[*l1 - 1], &para.lines[*l1]);
let top = y + last.top - first.top;
let text_end = top + (last.baseline - last.top) + last.text_desc;
assert!(text_end <= bottom + 0.01, "{multiple} shift {shift}: the last line's text ends at {text_end}, below the margin at {bottom}");
let next_text_end = top + last.height + (next.baseline - next.top) + next.text_desc;
assert!(
next_text_end > bottom + 0.01,
"{multiple} shift {shift}: the next line's text would end at {next_text_end}, within the margin at {bottom}"
);
if top + last.height > bottom + 0.01 {
past_margin += 1;
}
}
assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin");
}
}
+220
View File
@@ -0,0 +1,220 @@
//! #3977: floating tables include a line's bottom edge; shapes keep a strict boundary.
//! The expected distinction is cyril265's Word for Mac 16.114.1 observation in the issue.
use super::*;
use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::{HeightRule, LineSpacing, ParaProps, TableFloat};
use wordcraft_doc::{CharProps, Paragraph, Pos, Table};
const LINE: f32 = 82.875;
const CONTACT: f32 = 403.5;
fn document(mode: u32) -> Document {
let mut d = Document::new();
d.settings.compat_mode = mode;
let ch = CharProps { font: Some("Source Sans 3".into()), size: Some(62.5), ..Default::default() };
d.body = (1..=6)
.map(|n| {
let mut p = Paragraph::with_text(&format!("P{n}"), ch.clone());
p.mark = ch.clone();
p.props = ParaProps {
space_before: Some(0.0),
space_after: Some(0.0),
line_spacing: Some(LineSpacing::Multiple(1.0)),
widow_control: Some(false),
..Default::default()
};
wordcraft_doc::para_block(p)
})
.collect();
d
}
fn add_table(d: &mut Document, top: f32, width: f32) {
let mut t = Table::new(1, 1, width);
t.props.style = None;
t.props.width = Some(width);
t.props.fixed = true;
t.props.cell_margins = Some([0.0; 4]);
t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: top, ..Default::default() });
t.rows[0].props.height = Some(50.0);
t.rows[0].props.height_rule = HeightRule::Exact;
d.body.insert(0, Arc::new(Block::Table(t)));
}
fn add_shape(d: &mut Document, top: f32, left: f32, width: f32, height: f32) {
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: width,
h: height,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: left, y: top, ..Default::default() },
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let block = d.body.iter().position(|b| matches!(&**b, Block::Para(_))).unwrap();
d.insert_object(&Pos::body(block, 0), shape, &Default::default()).unwrap();
}
fn line_positions(d: &Document) -> Vec<(f32, f32, f32)> {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
assert_eq!(l.pages.len(), 1);
let lines: Vec<_> = l.pages[0]
.items
.iter()
.filter_map(|item| match item {
Placed::Lines { path, x, y, para, story: StoryRef::Body, .. } if path.0.len() == 1 => {
assert_eq!(para.lines.len(), 1);
let line = &para.lines[0];
// Placed::Lines::y is already the top of its first displayed line.
Some((x + line.left, *y, line.height))
}
_ => None,
})
.collect();
assert_eq!(lines.len(), 6);
lines
}
#[test]
fn only_tables_include_exact_line_bottom_contact_in_all_compatibility_modes() {
for mode in [11, 12, 14, 15] {
let control = line_positions(&document(mode));
assert_eq!(control[3].1 + control[3].2, CONTACT);
assert!(control.iter().enumerate().all(|(n, l)| l.1 == 72.0 + n as f32 * LINE && l.2 == LINE));
for table in [false, true] {
for twips in [-1, 0, 1] {
let top = CONTACT + twips as f32 / 20.0;
let mut d = document(mode);
if table {
add_table(&mut d, top, 468.0);
} else {
add_shape(&mut d, top, 72.0, 468.0, 50.0);
}
let lines = line_positions(&d);
let moves = twips < 0 || (table && twips == 0);
let expected = if moves { top + 50.0 } else { control[3].1 };
assert!((lines[3].1 - expected).abs() < 0.001, "mode={mode}, table={table}, twips={twips}: {lines:?}");
assert_eq!(&lines[..3], &control[..3]);
// If P4 stays above, P5 is the first line that meets the object.
let next = if moves { lines[3].1 + LINE } else { top + 50.0 };
assert!((lines[4].1 - next).abs() < 0.001, "mode={mode}, table={table}, twips={twips}: {lines:?}");
assert_eq!(lines[5].1, lines[4].1 + LINE);
}
}
}
}
#[test]
fn a_word_skipping_a_narrow_strip_at_table_contact_resumes_when_the_table_ends() {
for mode in [11, 12, 14, 15] {
let mut d = document(mode);
// Before contact the shape leaves 200 pt on the left. At contact the table takes
// 168 pt of that, leaving a 32 pt strip, too narrow for P4. The table ends before
// the shape: P4 must use the newly available left strip, not jump below the shape.
add_table(&mut d, CONTACT, 168.0);
add_shape(&mut d, 72.0, 272.0, 268.0, 500.0);
let lines = line_positions(&d);
assert!((lines[3].1 - (CONTACT + 50.0)).abs() < 0.001, "mode={mode}: {lines:?}");
assert_eq!(lines[3].0, 72.0);
assert!(lines[..3].iter().enumerate().all(|(n, l)| l.1 == 72.0 + n as f32 * LINE));
}
}
#[test]
fn exact_spacing_does_not_make_mark_height_a_table_contact_boundary() {
for mode in [11, 12, 14, 15] {
for table in [false, true] {
let mut d = Document::new();
d.settings.compat_mode = mode;
let mut p = Paragraph::with_text("P1", CharProps { font: Some("Source Sans 3".into()), size: Some(11.0), ..Default::default() });
p.mark = CharProps { font: Some("Source Sans 3".into()), size: Some(62.5), ..Default::default() };
p.props =
ParaProps { line_spacing: Some(LineSpacing::Exactly(12.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
d.body = vec![wordcraft_doc::para_block(p)];
// The mark's natural 82.875 pt estimate ends far below the actual 12 pt line.
let top = 72.0 + LINE;
if table {
add_table(&mut d, top, 468.0);
} else {
add_shape(&mut d, top, 72.0, 468.0, 50.0);
}
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let (y, height) = l.pages[0]
.items
.iter()
.find_map(|item| match item {
Placed::Lines { story: StoryRef::Body, path, y, para, .. } if path.0.len() == 1 => Some((*y, para.lines[0].height)),
_ => None,
})
.unwrap();
assert_eq!(height, 12.0);
assert_eq!(y, 72.0, "mode={mode}, table={table}");
}
}
}
fn exact_document(mode: u32, mark_size: f32) -> Document {
let mut d = Document::new();
d.settings.compat_mode = mode;
let mut p = Paragraph::with_text("WWWWWWWWWWWW", CharProps { font: Some("Source Sans 3".into()), size: Some(11.0), ..Default::default() });
p.mark = CharProps { font: Some("Source Sans 3".into()), size: Some(mark_size), ..Default::default() };
p.props = ParaProps { line_spacing: Some(LineSpacing::Exactly(12.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
d.body = vec![wordcraft_doc::para_block(p)];
d
}
fn exact_line(d: &Document) -> (f32, f32) {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
assert_eq!(l.pages.len(), 1);
l.pages[0]
.items
.iter()
.find_map(|item| match item {
Placed::Lines { story: StoryRef::Body, path, x, y, para, .. } if path.0 == vec![(d.body.len() - 1) as u32] => {
assert_eq!(para.lines.len(), 1);
assert_eq!(para.lines[0].height, 12.0);
Some((x + para.lines[0].left, *y))
}
_ => None,
})
.unwrap()
}
#[test]
fn exact_table_contact_uses_the_spacing_with_larger_and_smaller_marks() {
for mode in [11, 12, 14, 15] {
for mark in [4.0, 62.5] {
for twips in [-1, 0, 1] {
let mut d = exact_document(mode, mark);
let top = 84.0 + twips as f32 / 20.0;
add_table(&mut d, top, 468.0);
let (_, y) = exact_line(&d);
let expected = if twips <= 0 { top + 50.0 } else { 72.0 };
assert!((y - expected).abs() < 0.001, "mode={mode}, mark={mark}, twips={twips}: {y}");
}
}
}
}
#[test]
fn exact_table_contact_uses_the_spacing_when_a_word_skips_a_strip() {
for mode in [11, 12, 14, 15] {
let mut d = exact_document(mode, 4.0);
// Anchor the shape in an earlier paragraph so the tested word has no inline anchor.
let Block::Para(p) = &*d.body[0] else { panic!("expected paragraph") };
let mut anchor = Paragraph::with_text("P0", p.mark.clone());
anchor.mark = p.mark.clone();
anchor.props = p.props.clone();
d.body.insert(0, wordcraft_doc::para_block(anchor));
add_table(&mut d, 96.0, 168.0);
add_shape(&mut d, 72.0, 272.0, 268.0, 500.0);
// The 32 pt strip cannot hold the word. The table ends before the shape,
// leaving 200 pt on the left, so skipping must stop at the table's bottom.
assert_eq!(exact_line(&d), (72.0, 146.0), "mode={mode}");
}
}
+41 -4
View File
@@ -26,6 +26,8 @@ pub struct Remote {
addr: String,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
next_id: u64,
/// Whether a connection ever succeeded (`lazy` starts without one; the app may open later).
connected: bool,
/// The connection leads to a loopback address: requests carry the control key.
send_key: bool,
/// The control key, looked up once per connection (the app picks a new one at every start).
@@ -83,7 +85,7 @@ impl Remote {
}
fn connect_with(addr: &str, find_key: KeyFinder) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, send_key: false, key: None, find_key };
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key };
r.reconnect()?;
if let Some(w) = r.key_warning() {
log::warn!("{w}");
@@ -91,12 +93,22 @@ impl Remote {
Ok(r)
}
/// A remote that connects on first use: the app may start after the MCP server, and a
/// server that exits at once reads as failed for the client's whole session (#4078).
pub fn lazy(addr: &str) -> Self {
Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key: Box::new(find_key) }
}
pub fn addr(&self) -> &str {
&self.addr
}
/// Why requests go without the control key (the address is not loopback), if they do.
pub fn key_warning(&self) -> Option<String> {
// Nothing is known about the address until a connection succeeds.
if !self.connected {
return None;
}
(!self.send_key).then(|| {
format!(
"{} is not a loopback address, so the control key is not sent (it only goes to 127.0.0.0/8, ::1 and localhost) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel",
@@ -118,6 +130,7 @@ impl Remote {
self.send_key = may_send_key(&self.addr, s.peer_addr().ok());
let read = s.try_clone()?;
self.conn = Some((BufReader::new(read), s));
self.connected = true;
return Ok(());
}
Err(e) => last = e,
@@ -158,7 +171,10 @@ impl Remote {
/// One request and its reply, with one retry on a new connection (the app may have
/// restarted; a new connection also reads the key again).
fn exchange(&mut self, id: u64, method: &str, params: &Value) -> Result<Value, String> {
let unreachable = |addr: &str, e: std::io::Error| format!("WordCraft app at {addr} is not reachable: {e}");
let unreachable = |addr: &str, e: std::io::Error, connected: bool| {
let retry = if connected { String::new() } else { "; will retry on each call".to_string() };
format!("WordCraft app at {addr} is not reachable: {e}{retry}")
};
let mut first = true;
let reply = loop {
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect() };
@@ -174,7 +190,7 @@ impl Remote {
Err(e) => {
self.conn = None;
if !first {
return Err(unreachable(&self.addr, e));
return Err(unreachable(&self.addr, e, self.connected));
}
first = false;
}
@@ -219,7 +235,11 @@ impl Backend for Remote {
}
fn describe(&self) -> String {
format!("connected to the WordCraft app at {}", self.addr)
if self.connected {
format!("connected to the WordCraft app at {}", self.addr)
} else {
format!("will connect to the WordCraft app at {} when it opens", self.addr)
}
}
}
@@ -332,6 +352,23 @@ mod tests {
}
}
#[test]
fn a_lazy_remote_serves_once_the_app_opens() {
// Nothing is listening: the call fails, but the server stays up and says it will retry.
let mut r = Remote::lazy("127.0.0.1:1");
r.find_key = fixed_key("k1");
let e = r.call("document.inspect", json!({})).unwrap_err();
assert!(e.contains("not reachable") && e.contains("will retry"), "{e}");
assert!(r.describe().contains("will connect to"));
// The app opens later: the next call connects and answers.
let (port, seen) = fake_app(|_| Some((json!({"ok": true, "result": 7}), false)));
r.addr = format!("127.0.0.1:{port}");
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(7));
assert!(r.key_warning().is_none());
assert_eq!(seen.recv_timeout(WAIT).unwrap()["key"], "k1");
assert!(r.describe().contains("connected to"));
}
#[test]
fn reads_the_key_again_on_a_new_connection() {
// The app restarted with a new key: the old key is refused (and the connection closed).
+88 -12
View File
@@ -59,6 +59,77 @@ fn agent_writes_a_formatted_document() {
assert!(p["percent"].as_f64().unwrap() > 50.0);
}
/// The document's paragraphs as (text, in a list) from `inspect_document`.
fn paragraphs(s: &mut Server) -> Vec<(String, bool)> {
let doc = tool(s, "inspect_document", json!({}));
doc["blocks"].as_array().unwrap().iter().map(|b| (b["text"].as_str().unwrap_or("").to_string(), !b["list"].is_null())).collect()
}
/// `type_text` with `paragraphs` is one command: it used to be a `text.newParagraph` and a
/// `text.insert` per line, each snapshotting the whole document for Undo (quadratic: 10,000 lines
/// took 2.8 s; one command takes 7 ms) and each its own Undo step (#3973). The document it makes
/// is the one the per-line way made, a CRLF is one break, and one Undo takes all of it back.
#[test]
fn type_text_paragraphs_is_one_step_and_makes_the_same_paragraphs() {
let per_line = |s: &mut Server, text: &str| {
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
tool(s, "execute", json!({"command": "text.newParagraph"}));
}
if !line.is_empty() {
tool(s, "type_text", json!({"text": line}));
}
}
};
let setup = |s: &mut Server, listed: bool| {
tool(s, "new_document", json!({}));
tool(s, "type_text", json!({"text": "Start"}));
if listed {
tool(s, "execute", json!({"command": "para.bullets"}));
}
};
for listed in [false, true] {
// Text AutoCorrect has nothing to change, so only the paragraphs are compared.
for text in ["One\nTwo\nThree", "One\n\n\nTwo", "\nLead", "Trail\n", "A b c\n Indented\n\tTabbed", "X"] {
let mut old = Server::new(Box::new(Headless::default()));
setup(&mut old, listed);
per_line(&mut old, text);
let mut new = Server::new(Box::new(Headless::default()));
setup(&mut new, listed);
tool(&mut new, "type_text", json!({"text": text, "paragraphs": true}));
assert_eq!(paragraphs(&mut new), paragraphs(&mut old), "{text:?} (in a list: {listed})");
}
}
// The lines are typed as they are: Enter's AutoCorrect (a capital for the next sentence, a
// fixed typo) doesn't run on the breaks. The per-line way capitalised "two" (and not the last
// line, which no Enter followed), so what was written depended on the line's place.
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
tool(&mut s, "type_text", json!({"text": "one\ntwo teh\nthree", "paragraphs": true}));
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
assert_eq!(texts, ["one", "two teh", "three"]);
// CRLF is one break per line end.
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
tool(&mut s, "type_text", json!({"text": "a\r\nb\r\nc", "paragraphs": true}));
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
assert_eq!(texts, ["a", "b", "c"]);
// One Undo step takes all the lines back.
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
tool(&mut s, "type_text", json!({"text": "keep"}));
// Moving the caret ends the typing that "keep" was part of, so it isn't undone with the rest.
tool(&mut s, "execute", json!({"command": "caret.docEnd"}));
tool(&mut s, "type_text", json!({"text": "\none\ntwo\nthree\nfour", "paragraphs": true}));
assert_eq!(paragraphs(&mut s).len(), 5);
tool(&mut s, "execute", json!({"command": "edit.undo"}));
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
assert_eq!(texts, ["keep"], "one Undo removes every line");
}
#[test]
fn resources() {
let mut s = Server::new(Box::new(Headless::default()));
@@ -120,17 +191,17 @@ fn headless_sessions_keep_the_dictionary_in_memory() {
assert!(h.session.user_dict_path.is_none());
}
/// A backend whose paragraph break is refused while text insertion would succeed.
struct RefusesParagraph {
calls: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
/// A backend that refuses every text insertion (a protected document, say).
struct RefusesInsert {
calls: std::sync::Arc<std::sync::Mutex<Vec<(String, Value)>>>,
}
impl crate::backend::Backend for RefusesParagraph {
impl crate::backend::Backend for RefusesInsert {
fn call(&mut self, _method: &str, params: Value) -> Result<Value, String> {
let command = params["command"].as_str().unwrap_or("").to_string();
self.calls.lock().unwrap().push(command.clone());
self.calls.lock().unwrap().push((command.clone(), params["params"].clone()));
match command.as_str() {
"text.newParagraph" => Err("paragraph break refused".into()),
"text.insert" => Err("editing refused".into()),
_ => Ok(json!({"ok": true})),
}
}
@@ -138,17 +209,22 @@ impl crate::backend::Backend for RefusesParagraph {
false
}
fn describe(&self) -> String {
"refuses paragraphs".into()
"refuses edits".into()
}
}
/// #1356: a refused paragraph break is reported and the next line is not typed after it.
/// #1356: a refused edit is reported and nothing is typed after it. The lines are one
/// `text.insert` now (#3973), so a protected document refuses all of them at once, never a
/// line at a time with the next one typed after the break that failed.
#[test]
fn type_text_stops_at_a_refused_paragraph_break() {
fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() {
let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() }));
let mut s = Server::new(Box::new(RefusesInsert { calls: calls.clone() }));
let r = call(&mut s, 11, "tools/call", json!({"name": "type_text", "arguments": {"text": "one\ntwo", "paragraphs": true}}));
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}");
assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]);
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("editing refused"), "{r}");
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "one command for all the lines: {calls:?}");
assert_eq!(calls[0].0, "text.insert");
assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break");
}
+6 -17
View File
@@ -102,7 +102,7 @@ pub fn tool_definitions() -> Value {
tool(
"type_text",
"Type text",
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.",
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (all in one step, one Undo; the line breaks are plain text breaks: no AutoCorrect or AutoFormat runs on them).",
obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]),
false
),
@@ -229,22 +229,11 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
"type_text" => {
let t = st("text").unwrap_or("");
if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) {
let mut last = Ok(Value::Null);
for (i, line) in t.split('\n').enumerate() {
if i > 0 {
last = exec(b, "text.newParagraph", json!({}));
if last.is_err() {
break;
}
}
if !line.is_empty() {
last = exec(b, "text.insert", json!({"text": line, "raw": true}));
}
if last.is_err() {
break;
}
}
wrap(last)
// One command for all the lines: `text.insert` takes a carriage return as a
// paragraph break. A command per line was quadratic in the document (each one
// snapshots all of it for Undo: 10,000 lines took 2.8 s, 7 ms in one command),
// and left an Undo step per line. CRLF is one break, not two (#3973).
wrap(exec(b, "text.insert", json!({"text": t.replace("\r\n", "\n").replace('\n', "\r"), "raw": true})))
} else {
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
}
+1 -1
View File
@@ -10,7 +10,7 @@ workspace = true
[dependencies]
wordcraft-doc = { workspace = true }
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
wordcraft-metafile = { workspace = true }
+25 -7
View File
@@ -27,7 +27,6 @@ use std::num::NonZeroU16;
use std::sync::Arc;
use craft_pdf::write::{self, Metadata, WriteError, Writer, backend as krilla};
use krilla::action::{Action, LinkAction};
use krilla::annotation::{Annotation, LinkAnnotation, Target};
use krilla::color::rgb;
use krilla::destination::XyzDestination;
@@ -1043,6 +1042,10 @@ impl Exporter<'_> {
tree
}
/// Paint one display item on the PDF surface in its current point-coordinate system.
/// Nested clips and turns are pushed and popped around their child items. Shape and path
/// outlines set their own dash and cap state, then clear their fill and stroke; ink retains
/// round caps independently of the optional shape-outline setting.
fn draw(&mut self, s: &mut Surface, d: &Draw) {
match d {
Draw::Figure { draws, .. } => {
@@ -1077,7 +1080,7 @@ impl Exporter<'_> {
}
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha, .. } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
Draw::Image { rect, media, crop, alpha, .. } => self.picture(s, rect, media, crop, *alpha),
Draw::Path { segs, fill: f, stroke, stroke_width, dash } => {
Draw::Path { segs, fill: f, stroke, stroke_width, dash, cap } => {
let mut pb = PathBuilder::new();
append_path(&mut pb, &wordcraft_layout::display::seg_path(segs));
let Some(p) = pb.finish() else { return };
@@ -1087,6 +1090,7 @@ impl Exporter<'_> {
// Width 0 (or none) is a hairline.
width: if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
dash: (!dash.is_empty()).then(|| StrokeDash { array: dash.clone(), offset: 0.0 }),
line_cap: pdf_line_cap(*cap),
..Default::default()
}));
if f.is_some() || stroke.is_some() {
@@ -1125,7 +1129,7 @@ impl Exporter<'_> {
s.draw_path(&p);
s.set_stroke(None);
}
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects, corner, dash } => {
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects, corner, dash, cap } => {
let Some(p) = shape_path(*kind, rect, *corner) else { return };
let can_fill = *kind != ShapeKind::Line;
let fx = effects.sanitized();
@@ -1150,6 +1154,7 @@ impl Exporter<'_> {
paint: rgb::Color::new(c.0, c.1, c.2).into(),
width: if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
dash: (!dash.is_empty()).then(|| StrokeDash { array: dash.clone(), offset: 0.0 }),
line_cap: pdf_line_cap(*cap),
..Default::default()
}));
if f.is_some() && can_fill || stroke.is_some() {
@@ -1595,11 +1600,11 @@ impl Exporter<'_> {
None => continue,
}
} else {
let url = target.trim();
if url.is_empty() {
continue;
// Unsafe targets (javascript:, file:, shares, other schemes) get no link; the text stays.
match write::uri_target(target) {
Some(t) => t,
None => continue,
}
Target::Action(Action::Link(LinkAction::new(url.to_string())))
};
v.push(Annotation::new_link(LinkAnnotation::new(rect, t), Some(target.clone())));
}
@@ -1889,6 +1894,10 @@ fn path_from(b: &kurbo::BezPath) -> Option<Path> {
/// Decode a picture for embedding (validated with the `image` crate first, so a broken file
/// can't fail the whole export).
fn load_image(data: &Arc<Vec<u8>>) -> Option<Image> {
// The shared probe also charges what the decoder holds beside its output (#3644).
if !wordcraft_geom::picture::probe(data).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES)) {
return None;
}
load_image_within(data, wordcraft_geom::MAX_DECODED_BYTES)
}
@@ -1917,5 +1926,14 @@ fn load_image_within(data: &Arc<Vec<u8>>, budget: u64) -> Option<Image> {
Some(Image::from_rgba8(dec.into_rgba8().into_raw(), w, h))
}
/// Map the document cap to the PDF backend for both shape and path outlines.
fn pdf_line_cap(cap: wordcraft_doc::connector::LineCap) -> LineCap {
match cap {
wordcraft_doc::connector::LineCap::Flat => LineCap::Butt,
wordcraft_doc::connector::LineCap::Square => LineCap::Square,
wordcraft_doc::connector::LineCap::Round => LineCap::Round,
}
}
#[cfg(test)]
mod tests;
+96
View File
@@ -6,6 +6,49 @@ use wordcraft_doc::{Block, CharProps, Document, ListKind, Paragraph, Table, Wate
use crate::*;
/// Verify preset lines and connectors emit the requested PDF line-cap operator.
#[test]
fn shape_line_caps_reach_pdf_content() {
use wordcraft_doc::{
connector::{LineCap as ShapeCap, ShapeExtra},
para::{Anchor, Float, ShapeKind, Wrap},
};
for kind in [ShapeKind::Line, ShapeKind::StraightConnector] {
for (cap, operator) in [(ShapeCap::Flat, None), (ShapeCap::Round, Some("1 J")), (ShapeCap::Square, Some("2 J"))] {
let shape = InlineObject::Shape {
kind,
w: 40.0,
h: 0.0,
fill: None,
stroke: Some(wordcraft_doc::props::Rgb::BLACK),
stroke_width: 10.0,
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() },
story: None,
freeform: None,
effects: Default::default(),
extra: ShapeExtra { cap, ..Default::default() },
};
let mut p = Paragraph::new();
p.insert_object(0, shape, &Default::default()).unwrap();
let mut d = Document::new();
d.body = vec![para_block(p)];
let bytes = export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap();
let raw = String::from_utf8_lossy(&bytes);
// Content operators can share a line: PDF separates them by whitespace.
let tokens: Vec<_> = raw.split_ascii_whitespace().collect();
let has_operator = |op: &str| {
let expected: Vec<_> = op.split_ascii_whitespace().collect();
tokens.windows(2).any(|pair| pair == expected.as_slice())
};
if let Some(op) = operator {
assert!(has_operator(op), "{kind:?} {cap:?}: missing PDF cap operator {op}");
} else {
assert!(!has_operator("1 J") && !has_operator("2 J"), "flat cap must remain butt");
}
}
}
}
fn png() -> Vec<u8> {
let img = image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([(x * 15) as u8, (y * 30) as u8, 200, 255]));
let mut buf = Vec::new();
@@ -1022,6 +1065,34 @@ fn a_crop_keeping_a_sliver_draws_the_source_that_much_wider() {
assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "emf: {whole} → {cropped}");
}
/// A JPEG whose headers say progressive (SOF2, a DC-only first scan) and `w` × `h`, its three
/// components unsubsampled, with no pixel data that size.
fn progressive_jpeg_header(w: u16, h: u16) -> Vec<u8> {
let mut jpeg = Vec::new();
image::RgbImage::new(16, 16).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
jpeg[sof + 1] = 0xC2;
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
for c in 0..3 {
jpeg[sof + 11 + 3 * c] = 0x11;
}
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
let n = usize::from(jpeg[sos + 4]);
jpeg[sos + 6 + 2 * n] = 0;
jpeg
}
/// #3644: a progressive JPEG's coefficients count toward the budget, beside its output.
#[test]
fn progressive_jpeg_coefficients_count_toward_the_image_budget() {
assert!(wordcraft_geom::decode_fits(7900, 7900, wordcraft_geom::MAX_DECODED_BYTES));
assert!(load_image(&Arc::new(progressive_jpeg_header(7900, 7900))).is_none());
let mut baseline = Vec::new();
image::RgbImage::new(64, 64).write_to(&mut std::io::Cursor::new(&mut baseline), image::ImageFormat::Jpeg).unwrap();
assert!(load_image(&Arc::new(baseline)).is_some(), "other JPEGs still embed");
}
/// #1502: an inline picture's Figure carries its own alt text, not the "picture" fallback.
#[test]
fn inline_picture_figure_keeps_its_alt_text() {
@@ -2315,3 +2386,28 @@ fn borders_are_dashed_with_words_patterns() {
assert!(raw.contains("[6 2]0 d") || raw.contains("[6 2] 0 d"), "the dashed border");
assert!(raw.contains("[3 1 1 1]0 d") || raw.contains("[3 1 1 1] 0 d"), "the dash-dot border");
}
#[test]
fn only_safe_link_targets_become_annotations() {
use hayro_syntax::object::{Array, Dict, String as PdfString};
let mut d = Document::new();
d.body = ["https://example.com/", "mailto:ada@example.com", "javascript:alert(1)", "file:///etc/passwd"]
.into_iter()
.map(|url| para_block(Paragraph::with_text("Click here", CharProps { link: Some(url.into()), ..Default::default() })))
.collect();
let bytes = export(&d, &PdfOptions::default()).unwrap();
assert_eq!(squash(&extract_text(&bytes)[0]).matches("Click here").count(), 4, "unlinked text stays");
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
let uris: Vec<String> = pdf.pages()[0]
.raw()
.get::<Array>(b"Annots")
.into_iter()
.flat_map(|a| a.iter::<Dict>().collect::<Vec<_>>())
.map(|a| {
let uri = a.get::<Dict>(b"A").unwrap().get::<PdfString>(b"URI").unwrap();
String::from_utf8_lossy(uri.as_ref()).into_owned()
})
.collect();
assert_eq!(uris, ["https://example.com/", "mailto:ada@example.com"]);
}
+15 -1
View File
@@ -717,7 +717,9 @@ fn spelling_issues(text: &str, lang: impl Fn(usize) -> ProofLang, opts: &Options
/// The whitespace-delimited token enclosing the word at `a..b` of `text`: its byte range, and
/// whether it looks like a web or e-mail address.
fn enclosing_token(text: &str, a: usize, b: usize) -> (usize, usize, bool) {
let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0);
// The token starts after the last white-space character, whatever its width (a no-break
// space is two bytes and an ideographic space is three).
let ts = text.get(..a).and_then(|t| t.char_indices().rev().find(|(_, c)| c.is_whitespace())).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len());
(ts, te, text.get(ts..te).is_some_and(is_address))
}
@@ -1507,6 +1509,18 @@ mod tests {
assert_eq!(bad.last(), Some(&"wugwug"), "{bad:?}");
}
#[test]
fn address_after_a_wide_space_is_an_internet_token() {
// A web or e-mail address after a no-break space or an ideographic space is skipped,
// as it is after a plain space.
let t = "see\u{a0}https://xyzq.example/blorp and\u{3000}www.qwirpy.example or\u{a0}zxq@mail.example wugwug";
let bad: Vec<&str> = check_spelling(t).iter().map(|i| &t[i.start..i.end]).collect();
assert_eq!(bad, vec!["wugwug"]);
let opts = Options { ignore_internet: false, ..Options::default() };
let bad: Vec<&str> = check_spelling_with(t, &opts).iter().map(|i| &t[i.start..i.end]).collect();
assert!(["xyzq", "blorp", "qwirpy", "zxq", "wugwug"].iter().all(|w| bad.contains(w)), "{bad:?}");
}
#[test]
fn spelling_scales_over_one_long_token() {
// #1335: thousands of punctuation-separated words inside a single
+1 -1
View File
@@ -9,7 +9,7 @@ description = "WordCraft rasteriser: pages and thumbnails from the layout displa
workspace = true
[dependencies]
wordcraft-geom = { workspace = true }
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
+8 -5
View File
@@ -17,6 +17,8 @@ fn media_cache_only_hits_the_same_allocation() {
c.insert("pic", &a, 7, 3);
assert_eq!(c.get("pic", &a), Some(7));
assert_eq!(c.get("pic", &b), None, "equal bytes in another allocation are another picture");
drop(a);
assert_eq!(c.get("pic", &Arc::new(vec![1u8, 2, 3])), None, "a freed source's address isn't reused");
}
#[test]
@@ -34,17 +36,18 @@ fn media_cache_is_bounded_by_bytes() {
}
#[test]
fn media_cache_counts_the_source_bytes_it_holds() {
fn media_cache_counts_what_it_keeps_and_lets_sources_go() {
let mut c: MediaCache<Option<u32>> = MediaCache { map: HashMap::new(), bytes: 0 };
let src = Arc::new(vec![0u8; 1000]);
c.insert("broken", &src, None, 0);
assert_eq!(c.bytes, 1000, "an entry that decoded to nothing still holds its source");
c.insert("pic", &src, Some(1), 24);
assert_eq!(c.bytes, 2024);
// A source over the whole budget is not kept, whatever it decoded to (calloc'd, so cheap).
assert_eq!(c.bytes, 24, "an entry counts what it decoded to");
assert_eq!(Arc::strong_count(&src), 1, "and doesn't keep its source alive");
// A source over the whole budget is cached by what it decoded to (calloc'd, so cheap), so a
// broken one isn't decoded again on every render either.
let huge = Arc::new(vec![0u8; CACHE_BYTES + 1]);
c.insert("huge", &huge, None, 0);
assert!(!c.map.contains_key("huge"));
assert_eq!(c.get("huge", &huge), Some(None));
assert_eq!(CACHE_BYTES * 2, 256 * 1024 * 1024, "the image and vector caches share about 256 MB");
}
+239 -22
View File
@@ -2,7 +2,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, Weak};
use vello_cpu::kurbo::{self, Affine, BezPath, Shape};
use vello_cpu::peniko;
@@ -140,16 +140,16 @@ const CACHE_BYTES: usize = 256 * 1024 * 1024 / 2;
/// Upper bound on cached entries per cache.
const CACHE_ENTRIES: usize = 256;
/// Decoded media by media key, shared across renders, with an approximate byte budget. An entry holds
/// the source bytes it was decoded from, so a hit needs the same allocation (`Arc::ptr_eq`), and that
/// allocation cannot be freed and its address reused while the entry lives.
/// Decoded media by media key, shared across renders, with an approximate byte budget. An entry knows
/// its source allocation weakly: a hit needs the same allocation, whose address can't be reused while
/// the entry lives, and the source bytes are still freed with the document.
struct MediaCache<T> {
map: HashMap<String, Entry<T>>,
bytes: usize,
}
struct Entry<T> {
src: Arc<Vec<u8>>,
src: Weak<Vec<u8>>,
val: T,
size: usize,
}
@@ -157,14 +157,12 @@ struct Entry<T> {
impl<T: Clone> MediaCache<T> {
fn get(&self, key: &str, src: &Arc<Vec<u8>>) -> Option<T> {
let e = self.map.get(key)?;
Arc::ptr_eq(&e.src, src).then(|| e.val.clone())
std::ptr::eq(e.src.as_ptr(), Arc::as_ptr(src)).then(|| e.val.clone())
}
/// Stores `val` (about `size` bytes) with its source, whose bytes the entry keeps alive and so counts
/// too. Clears the cache first when the budget or entry cap is hit; an entry larger than the whole
/// budget is not kept.
/// Stores `val` (about `size` bytes) for `src`. Clears the cache first when the budget or entry
/// cap is hit; an entry larger than the whole budget is not kept.
fn insert(&mut self, key: &str, src: &Arc<Vec<u8>>, val: T, size: usize) {
let size = size.saturating_add(src.len());
if size > CACHE_BYTES {
return;
}
@@ -175,7 +173,7 @@ impl<T: Clone> MediaCache<T> {
self.map.clear();
self.bytes = 0;
}
self.map.insert(key.to_string(), Entry { src: src.clone(), val, size });
self.map.insert(key.to_string(), Entry { src: Arc::downgrade(src), val, size });
self.bytes = self.bytes.saturating_add(size);
}
}
@@ -202,6 +200,10 @@ const MAX_DECODE_SIDE: u32 = u16::MAX as u32;
/// Decode an encoded image to a premultiplied pixmap. The dimension limit above and the default
/// allocation cap (512 MB, what `load_from_memory` applied) bound the decode during paint.
pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
// The shared probe also charges what the decoder holds beside its output (#3644).
if !wordcraft_geom::picture::probe(bytes).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES)) {
return None;
}
decode_pixmap_within(bytes, wordcraft_geom::MAX_DECODED_BYTES)
}
@@ -410,10 +412,10 @@ const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024;
fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine) {
let Some(pic) = &doc.settings.page_picture else { return };
let Some(bytes) = doc.media.get(&pic.media) else { return };
match image_size(bytes) {
Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => {}
let (nw, nh) = match image_size(bytes) {
Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => (w as f64, h as f64),
_ => return,
}
};
let Some(pm) = pixmap_for(doc, &pic.media) else { return };
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
let (w, h) = (page.w as f64, (page.h as f64).min(1e6));
@@ -421,7 +423,8 @@ fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view:
return;
}
let tile = pic.fill_type.eq_ignore_ascii_case("tile");
let (sx, sy) = if tile { (0.75, 0.75) } else { (w / pw, h / ph) };
// Tiles at the picture's natural size, whatever size its cached pixmap is kept at.
let (sx, sy) = if tile { (0.75 * nw / pw, 0.75 * nh / ph) } else { (w / pw, h / ph) };
let extend = if tile { peniko::Extend::Repeat } else { peniko::Extend::Pad };
ctx.set_transform(view);
ctx.set_paint(vello_cpu::Image {
@@ -438,12 +441,60 @@ fn pixmap_for(doc: &Document, key: &str) -> Option<Arc<Pixmap>> {
if let Some(v) = lock(images()).get(key, bytes) {
return v;
}
let pm = decode_pixmap(bytes).map(Arc::new);
let pm = decode_pixmap(bytes).map(|p| Arc::new(cacheable(p)));
let size = pm.as_ref().map_or(0, |p| p.width() as usize * p.height() as usize * 4);
lock(images()).insert(key, bytes, pm.clone(), size);
pm
}
/// A decoded picture at a size the media cache keeps. One too large for the whole cache is
/// box-averaged down to half of it, so it is decoded once instead of on every render (#3665); pages
/// draw it scaled to its frame either way.
fn cacheable(pm: Pixmap) -> Pixmap {
let bytes = pm.width() as usize * pm.height() as usize * 4;
if bytes <= CACHE_BYTES {
return pm;
}
shrink(&pm, (CACHE_BYTES / 2 / 4) as u64).unwrap_or(pm)
}
/// `pm` averaged over square blocks, the smallest whole number of pixels a side that brings it to
/// at most `max_pixels`; None when it already fits or none are allowed. Premultiplied pixels
/// average without colour fringes.
fn shrink(pm: &Pixmap, max_pixels: u64) -> Option<Pixmap> {
let (w, h) = (usize::from(pm.width()), usize::from(pm.height()));
if (w * h) as u64 <= max_pixels || max_pixels == 0 {
return None;
}
let mut k = (((w * h) as f64 / max_pixels as f64).sqrt().ceil() as usize).max(2);
while (w.div_ceil(k) * h.div_ceil(k)) as u64 > max_pixels {
k += 1;
}
let (nw, nh) = (w.div_ceil(k), h.div_ceil(k));
let mut out = Vec::with_capacity(nw * nh);
let mut sums = vec![[0u64; 4]; nw];
for (oy, band) in pm.data().chunks(w * k).enumerate() {
sums.fill([0; 4]);
for row in band.chunks(w) {
for (sum, block) in sums.iter_mut().zip(row.chunks(k)) {
for p in block {
sum[0] += u64::from(p.r);
sum[1] += u64::from(p.g);
sum[2] += u64::from(p.b);
sum[3] += u64::from(p.a);
}
}
}
let rows = (h - oy * k).min(k);
for (ox, sum) in sums.iter().enumerate() {
let n = (((w - ox * k).min(k)) * rows).max(1) as u64;
let [r, g, b, a] = sum.map(|s| (s / n) as u8);
out.push(vello_cpu::color::PremulRgba8 { r, g, b, a });
}
}
Some(Pixmap::from_parts(out, nw as u16, nh as u16))
}
/// Options for a page raster.
#[derive(Clone, Debug)]
pub struct RenderOptions {
@@ -465,12 +516,24 @@ pub struct RenderOptions {
pub dark: bool,
/// The grey that black becomes on a dark page (white paper turns this, not pure black).
pub dark_paper: u8,
/// Read Mode's Page Color: paper drawn instead of the document's page color and page
/// picture. `None` shows the document's page background. Screen only.
pub read_paper: Option<Rgb>,
}
/// Default grey for a dark page's paper.
pub const DARK_PAPER: u8 = 0x33;
/// Read Mode's Sepia paper: Word's RGB (112, 66, 20) at 80% transparency over white.
pub const SEPIA_PAPER: Rgb = Rgb(0xE2, 0xD9, 0xD0);
impl RenderOptions {
/// The on-screen color of the paper under `doc`'s pages: [`RenderOptions::read_paper`],
/// else the document's page color, else [`RenderOptions::paper`].
pub fn paper_of(&self, doc: &Document) -> Rgb {
self.ink(self.read_paper.or(doc.settings.page_color).unwrap_or(self.paper))
}
/// The on-screen colour for a document colour (see [`RenderOptions::dark`]).
pub fn ink(&self, c: Rgb) -> Rgb {
if !self.dark {
@@ -547,6 +610,7 @@ impl Default for RenderOptions {
hinting: false,
dark: false,
dark_paper: DARK_PAPER,
read_paper: None,
}
}
}
@@ -573,9 +637,11 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
let (w16, h16) = (cw as u16, ch as u16);
let mut ctx = context_pool::CONTEXTS.take(w16, h16);
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0));
ctx.set_paint(color(opts.paper_of(doc), 1.0));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
draw_page_picture(&mut ctx, doc, page, view);
if opts.read_paper.is_none() {
draw_page_picture(&mut ctx, doc, page, view);
}
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
if let Some(wm) = &doc.settings.watermark {
draw_watermark(&mut ctx, view, page, wm, opts);
@@ -608,7 +674,7 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
/// pictures and turned or rotated content are left out. The caret takes its colour from this
/// so it shows against whatever lies behind it (#299).
pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, y: f32) -> Rgb {
let paper = opts.ink(doc.settings.page_color.unwrap_or(opts.paper));
let paper = opts.paper_of(doc);
if !(x.is_finite() && y.is_finite()) {
return paper;
}
@@ -802,6 +868,10 @@ fn wave_path(x0: f64, y0: f64, x1: f64, y1: f64, amp: f64) -> BezPath {
p
}
/// Rasterize one display item with `view` mapping its current point coordinates to pixels.
/// `visible` is a culling area in those point coordinates; nested clips intersect it and turns
/// transform it into the child's coordinates. Shape and path strokes apply their stored caps,
/// while ink uses its tool's round ends and joins.
fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visible: &kurbo::Rect, opts: &RenderOptions) {
match it {
Draw::Figure { draws, .. } => {
@@ -1018,7 +1088,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.pop_layer();
}
}
Draw::Path { segs, fill, stroke, stroke_width, dash } => {
Draw::Path { segs, fill, stroke, stroke_width, dash, cap } => {
let path = wordcraft_layout::display::seg_path(segs);
if path.elements().is_empty() {
return;
@@ -1032,7 +1102,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
// Width 0 (or none) is a hairline.
let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 };
ctx.set_paint(color(opts.ink(*s), 1.0));
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_dashes(0.0, dash.iter().map(|v| f64::from(*v))));
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, dash.iter().map(|v| f64::from(*v))));
ctx.stroke_path(&path);
}
}
@@ -1066,7 +1136,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner, dash } => {
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner, dash, cap } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
let path = shape_path(*kind, r, *corner);
ctx.set_transform(view);
@@ -1122,6 +1192,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.set_paint(color(opts.ink(*s), 1.0));
ctx.set_stroke(
kurbo::Stroke::new(if stroke_width.is_nan() { 0.25 } else { stroke_width.clamp(0.25, 200.0) } as f64)
.with_caps(screen_cap(*cap))
.with_dashes(0.0, dash.iter().map(|v| f64::from(*v))),
);
ctx.stroke_path(&path);
@@ -1179,6 +1250,15 @@ fn ink_path(pts: &[(f32, f32)]) -> Option<BezPath> {
Some(p)
}
/// Map the document cap to the raster backend without changing ink-tool stroke styles.
fn screen_cap(cap: wordcraft_doc::connector::LineCap) -> kurbo::Cap {
match cap {
wordcraft_doc::connector::LineCap::Flat => kurbo::Cap::Butt,
wordcraft_doc::connector::LineCap::Square => kurbo::Cap::Square,
wordcraft_doc::connector::LineCap::Round => kurbo::Cap::Round,
}
}
/// Outline of a basic shape in a rectangle; `corner` is a rounded rectangle's
/// ([`wordcraft_doc::connector::ShapeExtra::corner`]).
pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option<u32>) -> BezPath {
@@ -1394,6 +1474,7 @@ mod tests {
effects: Default::default(),
corner: None,
dash: Vec::new(),
cap: Default::default(),
};
let at = |x: f32, y: f32| {
let mut bg = [255.0; 3];
@@ -1662,6 +1743,44 @@ mod tests {
assert_eq!(&img.pixels[0..3], &[255, 255, 255]);
}
/// Read Mode's Page Color: the reading paper replaces the document's page color and page
/// picture on screen, in the raster and under the caret; the text keeps its color on Sepia.
#[test]
fn read_paper_replaces_the_page_background() {
let mut png = Vec::new();
image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 200, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut d = Document::from_text("Hello WordCraft");
d.media.insert("bg.png".into(), Arc::new(png));
d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40));
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let page = &l.pages[0];
let at = |img: &Rendered, x: u32, y: u32| {
let i = ((y * img.width + x) * 4) as usize;
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]]
};
let sepia = RenderOptions { read_paper: Some(SEPIA_PAPER), ..Default::default() };
let white = RenderOptions { read_paper: Some(Rgb::WHITE), ..Default::default() };
assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 5, 5), [0x10, 0x10, 0x40], "the page color");
assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 5, 5), [0xE2, 0xD9, 0xD0]);
assert_eq!(at(&render_page(&d, page, 1.0, &white), 5, 5), [255, 255, 255]);
assert_eq!(background_at(&d, page, &sepia, 5.0, 5.0), SEPIA_PAPER);
assert_eq!(background_at(&d, page, &white, 5.0, 5.0), Rgb::WHITE);
let inverse = RenderOptions { read_paper: Some(Rgb::WHITE), dark: true, ..Default::default() };
assert_eq!(background_at(&d, page, &inverse, 5.0, 5.0), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER));
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "tile".into(), title: String::new() });
assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 300, 300), [10, 200, 30], "the page picture");
assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 300, 300), [0xE2, 0xD9, 0xD0]);
// The text is drawn in its own color on sepia paper: its darkest pixel is within 8 levels of its value on white.
d.settings.page_picture = None;
d.settings.page_color = None;
let darkest = |img: &Rendered| img.pixels.chunks(4).map(|p| p[0].min(p[1]).min(p[2])).min().unwrap_or(255);
let plain = render_page(&d, page, 2.0, &RenderOptions::default());
let on_sepia = render_page(&d, page, 2.0, &sepia);
assert!(darkest(&plain) < 0x40 && darkest(&on_sepia) <= darkest(&plain) + 8, "{} {}", darkest(&plain), darkest(&on_sepia));
}
/// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so
/// the header parses), with no real pixel data.
fn ihdr_only_png(w: u32, h: u32) -> Vec<u8> {
@@ -1726,6 +1845,67 @@ mod tests {
assert!(decode_pixmap(b"not an image").is_none());
}
/// A JPEG whose headers say progressive (SOF2, a DC-only first scan) and `w` × `h`, its three
/// components unsubsampled, with no pixel data that size.
fn progressive_jpeg_header(w: u16, h: u16) -> Vec<u8> {
let mut jpeg = Vec::new();
image::RgbImage::new(16, 16).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
jpeg[sof + 1] = 0xC2;
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
for c in 0..3 {
jpeg[sof + 11 + 3 * c] = 0x11;
}
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
let n = usize::from(jpeg[sos + 4]);
jpeg[sos + 6 + 2 * n] = 0;
jpeg
}
#[test]
fn progressive_jpeg_coefficients_count_toward_the_decode_budget() {
// Two RGBA buffers of 7900 × 7900 fit 512 MB, but RGB output beside three full-size
// coefficient planes (9 bytes a pixel) doesn't (#3644).
let jpeg = progressive_jpeg_header(7900, 7900);
assert!(wordcraft_geom::decode_fits(7900, 7900, wordcraft_geom::MAX_DECODED_BYTES));
assert!(decode_pixmap(&jpeg).is_none());
let mut baseline = Vec::new();
image::RgbImage::new(64, 64).write_to(&mut std::io::Cursor::new(&mut baseline), image::ImageFormat::Jpeg).unwrap();
assert!(decode_pixmap(&baseline).is_some(), "other JPEGs still draw");
}
/// #3665: a picture too large for the whole cache is kept at half of it, so it is cached
/// instead of decoded again on every render.
#[test]
fn pictures_too_large_for_the_cache_are_kept_smaller() {
let px = vello_cpu::color::PremulRgba8 { r: 40, g: 80, b: 120, a: 200 };
// 6000 × 6000 is 144 MB of RGBA, past the 128 MB cache.
let big = Pixmap::from_parts(vec![px; 6000 * 6000], 6000, 6000);
let small = cacheable(big);
let bytes = small.width() as usize * small.height() as usize * 4;
assert!(bytes <= CACHE_BYTES / 2, "{} x {}", small.width(), small.height());
assert!(small.width() >= 3000 && small.width() == small.height(), "aspect kept");
assert!(small.data().iter().all(|p| (p.r, p.g, p.b, p.a) == (40, 80, 120, 200)), "flat stays flat");
let mut cache = MediaCache { map: HashMap::new(), bytes: 0 };
// A source file larger than the whole cache (an uncompressed BMP, say) doesn't count against it.
let src = Arc::new(vec![0u8; CACHE_BYTES + 1]);
cache.insert("big", &src, Some(Arc::new(small)), bytes);
assert!(cache.get("big", &src).is_some(), "cached");
// Pictures the cache holds as they are keep their size.
assert_eq!(cacheable(Pixmap::from_parts(vec![px; 100 * 50], 100, 50)).width(), 100);
}
#[test]
fn shrinking_averages_premultiplied_pixels() {
let p = |v: u8| vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: 255 };
let pm = Pixmap::from_parts(vec![p(0), p(100), p(200), p(40)], 4, 1);
let out = shrink(&pm, 2).unwrap();
assert_eq!((out.width(), out.height()), (2, 1));
assert_eq!(out.data().iter().map(|q| q.r).collect::<Vec<_>>(), [50, 120]);
assert!(shrink(&pm, 4).is_none() && shrink(&pm, 0).is_none());
}
#[test]
fn dimmed_header_image_renders() {
let mut d = Document::from_text("body");
@@ -2335,6 +2515,43 @@ mod tests {
assert!(dashed.pixel(406, 400)[1] < 80);
}
/// Distinguish flat, square and round endpoints for both preset lines and connector paths.
#[test]
fn shape_line_caps_reach_the_screen() {
use wordcraft_doc::{
connector::{LineCap, ShapeExtra},
para::{Anchor, Float, InlineObject, Wrap},
};
// A preset line uses Draw::Shape; a straight connector uses Draw::Path.
for kind in [ShapeKind::Line, ShapeKind::StraightConnector] {
for cap in [LineCap::Flat, LineCap::Square, LineCap::Round] {
let shape = InlineObject::Shape {
kind,
w: 40.0,
h: 0.0,
fill: None,
stroke: Some(Rgb::BLACK),
stroke_width: 10.0,
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() },
story: None,
freeform: None,
effects: Default::default(),
extra: ShapeExtra { cap, ..Default::default() },
};
let mut p = wordcraft_doc::Paragraph::new();
p.insert_object(0, shape, &Default::default()).unwrap();
let mut d = Document::new();
d.body = vec![wordcraft_doc::para_block(p)];
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let image = render_page(&d, &l.pages[0], 4.0, &RenderOptions::default());
let painted = |x, y| image.pixel(x, y)[0] < 80;
assert!(painted(404, 400), "line interior: {kind:?} {cap:?}");
assert_eq!(painted(388, 400), cap != LineCap::Flat, "past the endpoint: {kind:?} {cap:?}");
assert_eq!(painted(384, 384), cap == LineCap::Square, "square corner: {kind:?} {cap:?}");
}
}
}
#[test]
fn zero_width_connector_dashes() {
use wordcraft_doc::{
+8 -3
View File
@@ -12,9 +12,10 @@ workspace = true
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n
# catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in
# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247).
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" }
wordcraft-geom = { workspace = true }
# `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing
# (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values.
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-doc = { workspace = true }
unicode-bidi = { workspace = true }
wordcraft-fonts = { workspace = true }
@@ -38,6 +39,10 @@ sys-locale = { version = "0.3", features = ["js"] }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "=5.19.0"
# Comment times in the user's own time zone (`local_time.rs`); the web app asks the browser.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
chrono = { version = "0.4.45", default-features = false, features = ["clock"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32).
js-sys = "0.3"
+28 -2
View File
@@ -137,7 +137,7 @@ impl AutoScroll {
vec2(0.0, scroll_delta(displacement, dt))
}
/// Draw a marker at the anchor, above the pages, and the up-down cursor. Run after the
/// Draw a marker at the anchor, above the pages, and the cursor. Run after the
/// canvas has set its own cursor.
pub(crate) fn paint(&self, ui: &egui::Ui, viewport: egui::Rect) {
let Some(anchor) = self.anchor else { return };
@@ -152,7 +152,9 @@ impl AutoScroll {
Stroke::NONE,
));
}
ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical);
// No pointer position (outside the window) counts as the dead zone: `update` cancels the gesture then.
let displacement = ui.ctx().input(|i| i.pointer.hover_pos()).map_or(0.0, |p| p.y - anchor.y);
ui.ctx().set_cursor_icon(cursor_icon(displacement));
}
}
@@ -170,6 +172,18 @@ fn scroll_delta(displacement: f32, dt: f32) -> f32 {
-displacement.signum() * speed * dt.clamp(0.0, 0.05)
}
/// The cursor for a pointer `displacement` points (positive = below) from the anchor: the
/// up-down arrow while it rests in the dead zone, otherwise an arrow towards the scroll direction.
fn cursor_icon(displacement: f32) -> CursorIcon {
if !displacement.is_finite() || displacement.abs() <= DEAD_ZONE {
CursorIcon::ResizeVertical
} else if displacement < 0.0 {
CursorIcon::ResizeNorth
} else {
CursorIcon::ResizeSouth
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -192,4 +206,16 @@ mod tests {
assert_eq!(scroll_delta(1000.0, invalid), 0.0);
}
}
#[test]
fn cursor_follows_the_scroll_direction_outside_the_dead_zone() {
for y in [-15.0, -1.0, 0.0, 1.0, 15.0] {
assert_eq!(cursor_icon(y), CursorIcon::ResizeVertical);
}
assert_eq!(cursor_icon(-16.0), CursorIcon::ResizeNorth);
assert_eq!(cursor_icon(16.0), CursorIcon::ResizeSouth);
for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_eq!(cursor_icon(invalid), CursorIcon::ResizeVertical);
}
}
}
+92 -17
View File
@@ -785,6 +785,7 @@ fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>, hinted: bool) -
hinted.hash(&mut h);
let v = &app.session.view;
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
(v.read_mode, v.read_page_color).hash(&mut h);
app.session.prefs.markup.hash(&mut h);
dim_body.hash(&mut h);
format!("{:?}{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.page_picture, app.session.doc.settings.watermark)
@@ -1064,7 +1065,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0));
}
// View › Switch Modes: dark paper, inverted document colours.
let dark_page = app.session.view.dark_mode;
// In Read Mode the Page color setting is used instead: Inverse draws the dark page, Sepia
// draws sepia paper and None draws white paper.
use wordcraft_engine::cmd::view::ReadPageColor;
let read_mode = app.session.view.read_mode;
let dark_page = if read_mode { app.session.view.read_page_color == ReadPageColor::Inverse } else { app.session.view.dark_mode };
let sepia_page = read_mode && app.session.view.read_page_color == ReadPageColor::Sepia;
let dark_paper = wordcraft_render::DARK_PAPER;
let show_ruler = app.session.view.ruler
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
@@ -1367,7 +1373,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
};
// Shadow and paper.
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
let paper = if dark_page {
Color32::from_gray(dark_paper)
} else if sepia_page {
let s = wordcraft_render::SEPIA_PAPER;
Color32::from_rgb(s.0, s.1, s.2)
} else {
Color32::WHITE
};
painter.rect_filled(visual_sr, 0.0, paper);
if tiled {
// Render before painting to keep the frame sharp immediately after an edit or
// zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling.
@@ -1951,7 +1965,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
{
let opts = page_render_options(app, dim_body);
let bg = if on_visible_page(&rects, &geo.bands, p) {
opts.ink(app.session.doc.settings.page_color.unwrap_or(opts.paper))
opts.paper_of(&app.session.doc)
} else {
let c = Tokens::get(ui.ctx()).canvas;
wordcraft_doc::props::Rgb(c.r(), c.g(), c.b())
@@ -2137,22 +2151,26 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
// Over the page the card goes under the commented line, not on it.
let top = (anchor.y + if over_page { 4.0 } else { -12.0 }).max(next_y);
let active = app.canvas.balloon == Some(*id);
// Lay out a read-only balloon's text: the comment, then each reply under a rule.
let mut lines: Vec<(std::sync::Arc<egui::Galley>, f32)> = Vec::new();
// Lay out a read-only balloon's text: the comment, then each reply under a rule, each
// with who wrote it and when. (galley, gap above, a rule above)
let mut lines: Vec<(std::sync::Arc<egui::Galley>, f32, bool)> = Vec::new();
let card = if active {
Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0)))
} else {
let dim = |r: bool| if r { t.text_dim } else { t.text };
lines.push((balloon_text(painter, author, semibold(fs), t.text, w - 16.0), 0.0));
let body = app.session.doc.plain_text(StoryRef::Part(c.part));
lines.push((balloon_text(painter, body.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0));
for rid in &thread {
let Some(r) = app.session.doc.comments.get(rid) else { continue };
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), 9.0));
let small = regular((fs * 0.9).max(8.0));
for (i, cid) in std::iter::once(*id).chain(thread.iter().copied()).enumerate() {
let Some(r) = app.session.doc.comments.get(&cid) else { continue };
let gap = if i == 0 { 0.0 } else { 9.0 };
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), gap, i > 0));
let when = crate::local_time::comment_time(r);
if !when.is_empty() {
lines.push((balloon_text(painter, when, small.clone(), t.text_dim, w - 16.0), 1.0, false));
}
let text = app.session.doc.plain_text(StoryRef::Part(r.part));
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0));
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0, false));
}
let h = lines.iter().map(|(g, gap)| g.size().y + gap).sum::<f32>() + 12.0;
let h = lines.iter().map(|(g, gap, _)| g.size().y + gap).sum::<f32>() + 12.0;
Rect::from_min_size(pos2(x0, top), vec2(w, h))
};
if !card.intersects(clip) {
@@ -2175,9 +2193,9 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
} else {
card_shapes(painter, &t, card, color, selected, None);
let mut y = card.min.y + 6.0;
for (i, (g, gap)) in lines.into_iter().enumerate() {
for (g, gap, rule) in lines {
y += gap;
if i >= 2 && i % 2 == 0 {
if rule {
// A reply starts: a hairline between it and what it answers.
painter.hline(card.min.x + 10.0..=card.max.x - 8.0, y - gap / 2.0, Stroke::new(1.0, t.border));
}
@@ -2349,8 +2367,9 @@ fn balloon_text(painter: &egui::Painter, text: String, font: egui::FontId, color
painter.layout_job(job)
}
/// The selected balloon: the comment and its replies as editable fields (each edit is one undo
/// step via `review.editComment`), an optional reply field, and Reply / Resolve / Delete.
/// The selected balloon: the comment and its replies, each with who wrote it and when, as
/// editable fields (each edit is one undo step via `review.editComment`), an optional reply
/// field, and Reply / Resolve / Delete.
/// `card` carries last frame's size; returns this frame's.
#[allow(clippy::too_many_arguments)]
fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u32, thread: &[u32], card: Rect, fs: f32, color: Color32) -> Rect {
@@ -2364,6 +2383,13 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u
let (font, bold, small) = (regular(fs), semibold(fs), regular((fs * 0.9).max(8.0)));
let focus = std::mem::take(&mut app.canvas.balloon_focus);
let name = |app: &WordApp, cid: u32| app.session.doc.comments.get(&cid).map(|c| author_name(&c.author)).unwrap_or_default();
// When it was written, in local time, under the author.
let when = |app: &WordApp, ui: &mut Ui, cid: u32| {
let when = app.session.doc.comments.get(&cid).map(crate::local_time::comment_time).unwrap_or_default();
if !when.is_empty() {
ui.label(egui::RichText::new(when).font(small.clone()).color(t.text_dim));
}
};
let resolved = app.session.doc.comments.get(&id).is_some_and(|c| c.resolved);
child.horizontal(|ui| {
ui.label(egui::RichText::new(name(app, id)).font(bold.clone()).color(t.text));
@@ -2371,10 +2397,12 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u
ui.label(egui::RichText::new(tl!("Resolved")).font(small.clone()).color(t.resolved_text));
}
});
when(app, &mut child, id);
crate::panes::comment_editor(app, &mut child, id, Some(font.clone()), focus && !app.canvas.balloon_reply);
for rid in thread {
child.add_space(4.0);
child.label(egui::RichText::new(name(app, *rid)).font(bold.clone()).color(t.text));
when(app, &mut child, *rid);
crate::panes::comment_editor(app, &mut child, *rid, Some(font.clone()), false);
}
if app.canvas.balloon_reply && crate::panes::reply_editor(app, &mut child, id, Some(font.clone()), focus) {
@@ -3596,6 +3624,13 @@ fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_render::Rende
opts.display.revisions = app.session.prefs.markup.clone();
opts.dark = app.session.view.dark_mode;
opts.dark_paper = wordcraft_render::DARK_PAPER;
// In Read Mode the Page color sets the paper and the dark page, and Switch Modes is ignored.
if app.session.view.read_mode {
use wordcraft_engine::cmd::view::ReadPageColor;
let color = app.session.view.read_page_color;
opts.read_paper = Some(if color == ReadPageColor::Sepia { wordcraft_render::SEPIA_PAPER } else { wordcraft_doc::props::Rgb::WHITE });
opts.dark = color == ReadPageColor::Inverse;
}
opts.display.dim_header = !dim_body;
opts.display.dim_body = dim_body;
opts
@@ -4092,6 +4127,46 @@ mod tests {
assert_eq!(at(&mut a, 2), Color32::WHITE, "again, from the cache");
}
/// Read Mode › View › Page Color: the reading columns are drawn on white, sepia or a dark
/// page, in place of the document's page color; other views keep the page color.
#[test]
fn read_mode_page_color_sets_the_reading_paper() {
use wordcraft_doc::props::Rgb;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello WordCraft")), Default::default());
a.run("design.pageColor", json!({"color": "#102040"})).unwrap();
let paper = |a: &WordApp| page_render_options(a, false).paper_of(&a.session.doc);
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "Print Layout shows the page color");
let print_key = view_key(&a, false, None);
a.run("view.readMode", json!({})).unwrap();
assert_eq!(paper(&a), Rgb::WHITE, "None: white, the page color ignored");
let none_key = view_key(&a, false, None);
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
assert!(!page_render_options(&a, false).dark, "Sepia keeps the text's colors");
let sepia_key = view_key(&a, false, None);
a.run("view.readPageColor", json!({"value": "inverse"})).unwrap();
assert!(page_render_options(&a, false).dark);
let d = wordcraft_render::DARK_PAPER;
assert_eq!(paper(&a), Rgb(d, d, d));
let inverse_key = view_key(&a, false, None);
let keys = [print_key, none_key, sepia_key, inverse_key];
for (i, k) in keys.iter().enumerate() {
assert!(!keys[..i].contains(k), "each paper has its own view key");
}
a.run("view.printLayout", json!({})).unwrap();
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "leaving Read Mode brings the page color back");
assert!(!page_render_options(&a, false).dark);
// With Switch Modes on, Read Mode's paper still follows the Page color: None is white and Sepia is sepia.
a.session.view.dark_mode = true;
a.run("view.readMode", json!({})).unwrap();
assert!(!page_render_options(&a, false).dark);
assert_eq!(paper(&a), Rgb::WHITE);
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
a.run("view.printLayout", json!({})).unwrap();
assert!(page_render_options(&a, false).dark, "Print Layout keeps Switch Modes");
}
/// #299: the caret's colour is worked out only when the caret is drawn: with text selected
/// there is no caret, so the page's draw list isn't built for it.
#[test]
+1 -1
View File
@@ -289,7 +289,7 @@ fn source(app: &WordApp, ctx: &egui::Context, media: &str) -> Option<egui::Textu
if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
return t;
}
let tex = image::load_from_memory(bytes).ok().map(|img| {
let tex = wordcraft_geom::picture::decode(bytes, wordcraft_geom::MAX_DECODED_BYTES).ok().map(|img| {
let img = if img.width().max(img.height()) > SOURCE_MAX_PX { img.thumbnail(SOURCE_MAX_PX, SOURCE_MAX_PX) } else { img };
let rgba = img.to_rgba8();
let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
+18 -2
View File
@@ -82,16 +82,17 @@ impl ChartDataForm {
Ok(json!({"data": {"categories": self.categories, "series": series}}))
}
// Default names become document text (saved and exported), so no isolate marks.
fn add_row(&mut self) {
if self.categories.len() < MAX_POINTS {
self.categories.push(crate::i18n::fmt(tl!("Category {n}"), &[("n", &(self.categories.len() + 1).to_string())]));
self.categories.push(crate::i18n::fmt_plain(tl!("Category {n}"), &[("n", &(self.categories.len() + 1).to_string())]));
self.cells.push(vec![String::new(); self.names.len()]);
}
}
fn add_series(&mut self) {
if self.names.len() < MAX_SERIES {
self.names.push(crate::i18n::fmt(tl!("Series {n}"), &[("n", &(self.names.len() + 1).to_string())]));
self.names.push(crate::i18n::fmt_plain(tl!("Series {n}"), &[("n", &(self.names.len() + 1).to_string())]));
for r in &mut self.cells {
r.push(String::new());
}
@@ -356,4 +357,19 @@ mod tests {
f.cells[1][0] = "abc".into();
assert!(f.params().is_err());
}
/// Default names are saved into the document, so an RTL interface must not add isolate marks.
#[test]
fn default_names_in_an_rtl_interface_have_no_isolates() {
crate::i18n::set_current(crate::i18n::fixture_lang("ar", "\tCategory {n}\tالفئة {n}\n\tSeries {n}\tالسلسلة {n}\n"));
let mut f = ChartDataForm::of(&ChartSpec::sample(ChartType::Column));
f.add_row();
f.add_series();
crate::i18n::set_current(crate::i18n::Lang::EN);
let (category, series) = (f.categories.last().unwrap(), f.names.last().unwrap());
assert!(category.starts_with("الفئة") && series.starts_with("السلسلة"), "{category:?} {series:?}");
for name in [category, series] {
assert!(!name.chars().any(|c| ('\u{2066}'..='\u{2069}').contains(&c)), "{name:?}");
}
}
}
+25 -1
View File
@@ -1039,9 +1039,15 @@ pub const FIELDS: &[(&str, &str, &str)] = &[
("SAVEDATE", "Date and Time", "When this document was most recently saved"),
("TIME", "Date and Time", "The time right now"),
("AUTHOR", "Document Information", "Who the document properties name as author"),
("COMMENTS", "Document Information", "Comments from the document properties"),
("FILENAME", "Document Information", "Name of the file this document is saved as"),
("KEYWORDS", "Document Information", "Keywords from the document properties"),
("LASTSAVEDBY", "Document Information", "Who last saved the document"),
("NUMCHARS", "Document Information", "How many characters the document holds"),
("NUMPAGES", "Document Information", "How many pages the whole document has"),
("NUMWORDS", "Document Information", "How many words the document holds"),
("REVNUM", "Document Information", "How many times the document has been saved"),
("SUBJECT", "Document Information", "Subject from the document properties"),
("TITLE", "Document Information", "Title from the document properties"),
("PAGE", "Numbering", "Number of the page the field sits on"),
("SECTION", "Numbering", "Number of the section the field sits in"),
@@ -1104,7 +1110,7 @@ pub const NUMBER_FORMATS: &[(&str, &str)] = &[
pub fn field_kind(name: &str) -> FieldKind {
match name {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => FieldKind::Date,
"PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" => FieldKind::Number,
"PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" | "NUMCHARS" | "REVNUM" => FieldKind::Number,
"REF" | "PAGEREF" => FieldKind::Bookmark,
"SEQ" => FieldKind::Sequence,
"MERGEFIELD" => FieldKind::Merge,
@@ -1574,4 +1580,22 @@ mod tests {
f.code = " ".into();
assert!(f.params().is_none());
}
/// The Field dialog lists Word's document information fields, and the counts take a number
/// format.
#[test]
fn field_dialog_lists_the_document_information_fields() {
let listed: Vec<&str> = FIELDS.iter().filter(|(_, cat, _)| *cat == "Document Information").map(|(n, _, _)| *n).collect();
for name in ["COMMENTS", "KEYWORDS", "LASTSAVEDBY", "NUMCHARS", "REVNUM", "SUBJECT"] {
assert!(listed.contains(&name), "{name} in {listed:?}");
}
let a = app();
let mut f = FieldForm::new(&a);
f.select("REVNUM");
f.number_format = "ROMAN".into();
f.preserve = false;
assert_eq!(f.instruction(), r"REVNUM \* ROMAN");
f.select("SUBJECT");
assert_eq!(f.instruction(), "SUBJECT");
}
}
+77
View File
@@ -35,6 +35,7 @@ pub enum LayoutDialog {
Sort(SortForm),
SplitCells(SplitCellsForm),
TextToTable(TextToTableForm),
TableToText(TableToTextForm),
Size(SizeForm),
PageNumbers(PageNumbersForm),
}
@@ -52,6 +53,7 @@ impl LayoutDialog {
LayoutDialog::Sort(_) => "tableSort",
LayoutDialog::SplitCells(_) => "splitCells",
LayoutDialog::TextToTable(_) => "textToTable",
LayoutDialog::TableToText(_) => "tableToText",
LayoutDialog::Size(_) => "pictureSize",
LayoutDialog::PageNumbers(_) => "pageNumbers",
}
@@ -70,6 +72,7 @@ impl LayoutDialog {
LayoutDialog::Sort(_) => "Sort",
LayoutDialog::SplitCells(_) => "Split Cells",
LayoutDialog::TextToTable(_) => "Convert Text to Table",
LayoutDialog::TableToText(_) => "Convert to Text",
LayoutDialog::Size(_) => "Size",
LayoutDialog::PageNumbers(_) => "Page Number Format",
}
@@ -89,6 +92,7 @@ pub fn open(name: &str, app: &mut WordApp) -> Option<LayoutDialog> {
"tableSort" => LayoutDialog::Sort(SortForm::read(app)?),
"splitCells" => LayoutDialog::SplitCells(SplitCellsForm::read(app)?),
"textToTable" => LayoutDialog::TextToTable(TextToTableForm::read(app)?),
"tableToText" => LayoutDialog::TableToText(TableToTextForm::read(app)?),
"pictureSize" => LayoutDialog::Size(SizeForm::read(app)?),
"pageNumbers" => LayoutDialog::PageNumbers(PageNumbersForm::read(app)),
_ => return None,
@@ -108,6 +112,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool {
LayoutDialog::Sort(f) => sort_ui(app, ui, f),
LayoutDialog::SplitCells(f) => split_cells_ui(app, ui, f),
LayoutDialog::TextToTable(f) => text_to_table_ui(app, ui, f),
LayoutDialog::TableToText(f) => table_to_text_ui(app, ui, f),
LayoutDialog::Size(f) => size_ui(app, ui, f),
LayoutDialog::PageNumbers(f) => page_numbers_ui(app, ui, f),
}
@@ -1120,6 +1125,55 @@ fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTableForm) ->
cancel
}
// ---------------------------------------------------------------------------------------------
// Convert to Text
/// The Convert to Text dialog (Table Layout › Data): what sets the cells of a row apart in the
/// text (`paragraph`, `tab`, `comma` or `other` with `other_char`). It opens on Tabs.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableToTextForm {
pub separator: String,
pub other_char: String,
}
impl TableToTextForm {
/// None when the caret isn't in a table.
pub fn read(app: &WordApp) -> Option<TableToTextForm> {
app.session.sel.focus.path.cell()?;
Some(TableToTextForm { separator: "tab".into(), other_char: "-".into() })
}
/// `table.toText` parameters for OK.
pub fn params(&self) -> Value {
let mut v = json!({"separator": self.separator});
if self.separator == "other" {
v["char"] = json!(self.other_char);
}
v
}
}
fn table_to_text_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TableToTextForm) -> bool {
heading(ui, "Separate text with");
ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraph marks"));
ui.radio_value(&mut f.separator, "tab".to_string(), tl!("Tabs"));
ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas"));
ui.horizontal(|ui| {
ui.radio_value(&mut f.separator, "other".to_string(), tl!("Other:"));
let r = ui.add(egui::TextEdit::singleline(&mut f.other_char).char_limit(1).desired_width(24.0));
if r.changed() {
f.separator = "other".into();
}
});
let ready = f.separator != "other" || !f.other_char.is_empty();
let (ok, cancel) = buttons(ui, "OK", ready);
if ok {
return run(app, "table.toText", f.params());
}
cancel
}
// ---------------------------------------------------------------------------------------------
// Size
@@ -1907,6 +1961,29 @@ mod tests {
assert_eq!(t.rows.iter().map(|r| r.cells.len()).collect::<Vec<_>>(), [3, 3, 3, 3]);
}
/// Convert to Text opens its dialog on Tabs; OK converts with the separator chosen.
#[test]
fn table_to_text_opens_its_dialog() {
let mut a = table_app(2, &["a", "b", "c", "d"]);
assert_eq!(a.run("table.toText", json!({})).unwrap(), json!({"pending": "tableToText"}));
assert!(a.session.doc.body.iter().any(|b| b.as_table().is_some()), "nothing converted yet");
let LayoutDialog::TableToText(mut f) = form(&mut a) else { panic!("table to text") };
assert_eq!((f.separator.as_str(), f.other_char.as_str()), ("tab", "-"));
assert_eq!(f.params(), json!({"separator": "tab"}));
f.separator = "other".into();
f.other_char = "/".into();
assert_eq!(f.params(), json!({"separator": "other", "char": "/"}));
a.run("table.toText", f.params()).unwrap();
assert!(a.dialog.is_none());
let texts: Vec<&str> = a.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect();
assert!(texts.contains(&"a/b") && texts.contains(&"c/d"), "{texts:?}");
assert!(a.session.doc.body.iter().all(|b| b.as_table().is_none()));
// Outside a table there is nothing to convert.
let mut a = app();
assert_eq!(a.run("ui.dialog", json!({"name": "tableToText"})).unwrap(), json!({"dialog": null}));
}
/// The envelope sizes go through the interface language's catalog and use the region's
/// decimal separator (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in
/// English).
+7
View File
@@ -348,11 +348,14 @@ impl AutoCorrectForm {
"smartQuotes": o.smart_quotes,
"fractions": o.fractions,
"ordinals": o.ordinals,
"boldItalic": o.bold_italic,
"dashes": o.dashes,
"links": o.links,
"bullets": o.bullets,
"numbering": o.numbering,
"borderLines": o.border_lines,
"tabIndent": o.tab_indent,
"headingStyles": o.heading_styles,
"autoAddFirstLetter": o.auto_add_first_letter,
"autoAddInitialCaps": o.auto_add_initial_caps,
})
@@ -395,12 +398,16 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo
ui.checkbox(&mut o.smart_quotes, tl!("Smart quotes instead of straight quotes"));
ui.checkbox(&mut o.fractions, tl!("Fractions (1/2) as fraction characters (½)"));
ui.checkbox(&mut o.ordinals, tl!("Ordinals (1st) as superscript"));
ui.checkbox(&mut o.bold_italic, tl!("*Bold* and _italic_ as real formatting"));
ui.checkbox(&mut o.dashes, tl!("Hyphens (--) as dashes (—)"));
ui.checkbox(&mut o.links, tl!("Internet addresses as links"));
heading(ui, "Apply as you type");
ui.checkbox(&mut o.bullets, tl!("Automatic bulleted lists"));
ui.checkbox(&mut o.numbering, tl!("Automatic numbered lists"));
ui.checkbox(&mut o.border_lines, tl!("Border lines"));
ui.checkbox(&mut o.heading_styles, tl!("Built-in Heading styles"));
heading(ui, "Automatically as you type");
ui.checkbox(&mut o.tab_indent, tl!("Set left- and first-indent with tabs and backspaces"));
}
_ => math_tab(ui, f),
}
+2 -1
View File
@@ -32,13 +32,14 @@
//! - [`get`]: one context's translation only, never the plain row (`spacing`'s "Open").
//! - [`location`]: a ribbon location such as `Home › Font`, segment by segment.
//! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them.
//! - [`fmt_plain`]: [`fmt`] without bidi isolate marks, for text that goes into the document.
use std::cell::Cell;
use std::sync::{LazyLock, OnceLock};
use craft_i18n::I18n;
pub use craft_i18n::fmt;
pub use craft_i18n::{fmt, fmt_plain};
include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs"));
+27
View File
@@ -160,6 +160,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.line(&[(5.0, 12.0), (3.5, 15.0), (8.0, 15.0)]);
pen.frect(2.0, 16.5, 18.0, 19.0, yellow);
}
"highlightNoColor" => {
pen.fill(&[(5.0, 12.0), (11.0, 4.0), (14.0, 7.0), (8.0, 15.0)], c);
pen.line(&[(5.0, 12.0), (3.5, 15.0), (8.0, 15.0)]);
}
"fontcolor" => {
pen.text(10.0, 9.0, 14.0, "A", c, false);
pen.frect(2.0, 16.5, 18.0, 19.0, red);
@@ -1617,3 +1621,26 @@ pub fn library(p: &Painter, r: Rect, icon: &wordcraft_render::icon_lib::Icon, c:
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn has_default_highlight_bar(name: &str) -> bool {
let ctx = egui::Context::default();
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, egui::vec2(20.0, 20.0)), name, Color32::BLACK, Color32::BLACK);
});
let yellow = Color32::from_rgb(0xF2, 0xC8, 0x11);
let found = output.shapes.iter().any(|shape| matches!(&shape.shape, Shape::Rect(rect) if rect.fill == yellow));
output.drop_without_applying_deltas();
found
}
/// #4087: No Color uses the pen-only icon, not the ordinary icon's yellow fallback bar.
#[test]
fn no_colour_highlight_icon_has_no_yellow_bar() {
assert!(has_default_highlight_bar("highlight"));
assert!(!has_default_highlight_bar("highlightNoColor"));
}
}
+169 -7
View File
@@ -63,6 +63,7 @@ pub mod icons;
pub mod ink;
pub mod keys;
pub mod keytips;
mod local_time;
pub mod mini_toolbar;
pub mod objects;
pub mod options;
@@ -127,6 +128,15 @@ pub struct Services {
/// #1315). The hook starts a ticket for such a save and reports how it ended; until then the
/// document's save isn't confirmed, and a New/Open held back by the Save Changes prompt waits.
pub async_saves: Option<async_saves::AsyncSaves>,
/// Web, framed by a page that keeps the files (`?host=parent`): set alongside `download`,
/// Save, Save As and Export hand the bytes to that page instead, with whether it was Save As
/// ([`HostSaveFn`]: true for a `file.saveAs` in a format that keeps everything, which File ›
/// Export to such a format runs too, and for a Save under a name the page never sent; false
/// for Save and for copies: the PDF and PNG exports and Save As in a format that doesn't keep
/// everything). Save then writes the opened file
/// under its own name and format, as on the desktop, and a Save or Save As that keeps
/// everything makes that name the document's file, so the next Save goes to it.
pub host_save: Option<HostSaveFn>,
/// Web: told whether the document has unsaved changes after each pass, for the browser's
/// leave-page guard (`beforeunload` runs between frames and can't ask the app).
pub on_dirty: Option<Box<dyn Fn(bool)>>,
@@ -223,6 +233,10 @@ pub enum DefaultApp {
/// Files delivered asynchronously.
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
/// the file, and the save failed.
pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
/// Whether a pasted picture belongs in the document: the page has keyboard focus, and no
/// Backstage or dialog is open (#1349). A focused text field (comment, Find box) means the page
/// does not have focus, so `canvas_focused` already covers it; egui's own "wants keyboard input"
@@ -1007,7 +1021,7 @@ impl WordApp {
}
// Web: saving and exporting become downloads.
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.web_save_name(id));
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
// A save with a password encrypts this and later saves (`file.save`'s `password`).
if matches!(id, "file.save" | "file.saveAs")
@@ -1032,19 +1046,31 @@ impl WordApp {
if let Some(saves) = &saves {
let _ = saves.take_started();
}
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
{
// An export is a copy, and so is a save in a format that doesn't keep everything (a
// page image, plain text): the document itself still has unsaved changes.
let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
// Save As tells the host page the document is now this file, which it only is when the
// save marks it saved. A Save under a name the page never sent (an untitled document,
// an imported .doc saved as .docx) is a new file too, never an overwrite.
let opened_name = self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string());
let new_file = id == "file.saveAs" || opened_name.as_deref() != Some(name.as_str());
let delivered = match (&self.services.host_save, &self.services.download) {
(Some(host), _) => host(&name, &bytes, marked_saved && new_file),
(None, Some(download)) => download(&name, &bytes),
(None, None) => Ok(()),
};
if let Err(e) = delivered {
let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &name), ("error", &e)]);
log::error!("download of {name} failed: {e}");
self.status(msg.clone());
return Err(msg);
}
// An export is a copy, and so is a save in a format that doesn't keep everything (a
// page image, plain text): the document itself still has unsaved changes.
let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
if marked_saved {
self.session.dirty = false;
// The host page keeps the file under this name now, as a desktop save would.
if self.services.host_save.is_some() {
self.session.path = Some(std::path::PathBuf::from(&name));
}
}
// The browser's picker writes the file later (#1315): until it says how that went,
// the document's save isn't confirmed.
@@ -1955,6 +1981,26 @@ impl WordApp {
.is_some_and(|e| e.eq_ignore_ascii_case("doc") || e.eq_ignore_ascii_case("dot"))
}
/// The name a browser Save or Export without a name uses. An export is named after the
/// document (`report.pdf`, not `report.docx.pdf`). A host page that keeps the files
/// ([`Services::host_save`]) gets Save back under the opened file's own name and format, as
/// the desktop app writes it, except an import-only Word 97–2003 file (#2445).
fn web_save_name(&self, id: &str) -> String {
match id {
"file.exportPdf" => return format!("{}.pdf", self.title_stem()),
"file.exportPng" => return format!("{}.png", self.title_stem()),
_ => {}
}
if id == "file.save"
&& self.services.host_save.is_some()
&& !self.legacy_word_path()
&& let Some(name) = self.session.path.as_ref().and_then(|p| p.file_name())
{
return name.to_string_lossy().to_string();
}
self.default_save_name()
}
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
/// otherwise .docx.
fn default_save_name(&self) -> String {
@@ -2776,6 +2822,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
// Split Cells and Convert Text to Table without settings ask for them (#2592).
"table.split" if params.as_object().is_none_or(|m| m.is_empty()) => Some("splitCells"),
"table.fromText" if params.as_object().is_none_or(|m| m.is_empty()) => Some("textToTable"),
// Convert to Text without a separator asks for one.
"table.toText" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableToText"),
// Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320).
"para.tabs" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tabs"),
"para.borders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("borders"),
@@ -6967,6 +7015,120 @@ mod tests {
}
}
/// Web inside a page that keeps the files: the app with `host_save` recording each file it
/// is handed (name, Save As, size).
fn host_app(fail: bool) -> (WordApp, std::rc::Rc<std::cell::RefCell<Vec<(String, bool, usize)>>>) {
let mut a = typed();
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let seen = got.clone();
a.services.download = Some(Box::new(|_, _| Err("downloads go to the host page".into())));
a.services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
if fail {
return Err("the page is gone".into());
}
seen.borrow_mut().push((name.to_string(), save_as, bytes.len()));
Ok(())
}));
(a, got)
}
/// Open `name` as the host page sends it (bytes of the typed document in that format).
fn host_open(a: &mut WordApp, name: &str) {
let bytes = wordcraft_engine::io::save_bytes_with(name, &typed().session.doc, None, &Default::default()).unwrap();
a.execute("file.open", json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})).unwrap();
a.run("text.insert", json!({"text": "Edited "})).unwrap();
assert!(a.session.dirty);
}
/// Save hands the opened file back under its own name and format; Save As says so and
/// becomes the file the next Save writes.
#[test]
fn host_saves_go_back_to_the_opened_file() {
let (mut a, got) = host_app(false);
host_open(&mut a, "report.odt");
a.execute("file.save", json!({})).unwrap();
assert!(!a.session.dirty);
a.run("text.insert", json!({"text": "More "})).unwrap();
a.execute("file.saveAs", json!({"path": "report copy.docx"})).unwrap();
assert!(!a.session.dirty);
a.run("text.insert", json!({"text": "Again "})).unwrap();
a.execute("file.save", json!({})).unwrap();
let got = got.borrow();
let calls: Vec<(&str, bool)> = got.iter().map(|(n, s, _)| (n.as_str(), *s)).collect();
assert_eq!(calls, [("report.odt", false), ("report copy.docx", true), ("report copy.docx", false)]);
assert!(got.iter().all(|(_, _, len)| *len > 0));
}
/// A Markdown file goes back as Markdown, which doesn't keep everything: the document stays
/// unsaved, as on the desktop.
#[test]
fn host_save_of_markdown_keeps_its_format_and_stays_unsaved() {
let (mut a, got) = host_app(false);
host_open(&mut a, "notes.md");
a.execute("file.save", json!({})).unwrap();
assert_eq!(got.borrow().first().map(|(n, s, _)| (n.as_str(), *s)), Some(("notes.md", false)));
assert!(a.session.dirty);
assert_eq!(a.session.path.as_deref(), Some(std::path::Path::new("notes.md")));
}
/// An import-only Word 97–2003 file is saved as a .docx, never back over itself (#2445). The
/// host page never had that name, so it's a new file: Save As.
#[test]
fn host_save_of_a_legacy_word_file_writes_a_docx() {
let (mut a, got) = host_app(false);
a.session.path = Some("old.doc".into());
a.execute("file.save", json!({})).unwrap();
assert_eq!(got.borrow().first().map(|(n, s, _)| (n.as_str(), *s)), Some(("old.docx", true)));
}
/// Saving a document the host page never sent is Save As, so the page doesn't overwrite a
/// file of the same name; the next Save goes back to it.
#[test]
fn host_save_of_an_untitled_document_is_save_as() {
let (mut a, got) = host_app(false);
a.execute("file.save", json!({})).unwrap();
a.run("text.insert", json!({"text": "More "})).unwrap();
a.execute("file.save", json!({})).unwrap();
let got = got.borrow();
let calls: Vec<bool> = got.iter().map(|(_, s, _)| *s).collect();
assert_eq!(calls, [true, false], "{got:?}");
assert_eq!(got.first().map(|(n, ..)| n), got.get(1).map(|(n, ..)| n));
}
/// Exports without a name are named after the document, not after its file.
#[test]
fn web_exports_are_named_after_the_document() {
let (mut a, got) = host_app(false);
host_open(&mut a, "report.docx");
a.execute("file.exportPdf", json!({})).unwrap();
a.execute("file.exportPng", json!({})).unwrap();
let names: Vec<(String, bool)> = got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect();
assert_eq!(names, [("report.pdf".to_string(), false), ("report.png".to_string(), false)]);
assert!(a.session.dirty, "an export is a copy");
}
/// Save As in a format that doesn't keep everything (File › Export to PDF or Markdown) is a
/// copy: the host page isn't told the document is now that file.
#[test]
fn host_save_as_a_copy_format_is_not_save_as() {
let (mut a, got) = host_app(false);
host_open(&mut a, "report.docx");
a.execute("file.saveAs", json!({"path": "report.pdf"})).unwrap();
a.execute("file.saveAs", json!({"path": "report.md"})).unwrap();
let calls: Vec<(String, bool)> = got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect();
assert_eq!(calls, [("report.pdf".to_string(), false), ("report.md".to_string(), false)]);
assert_eq!(a.session.path.as_deref(), Some(std::path::Path::new("report.docx")));
}
/// The page not getting the file is a failed save: the document keeps its changes.
#[test]
fn a_host_save_that_fails_leaves_the_document_unsaved() {
let (mut a, _) = host_app(true);
host_open(&mut a, "report.docx");
assert!(a.execute("file.save", json!({})).is_err());
assert!(a.session.dirty);
}
/// Macro-enabled documents and templates keep everything (#172), so once saved in this
/// session AutoSave covers them like a .docx.
#[test]
+221
View File
@@ -0,0 +1,221 @@
//! Comment times in the user's own time zone. A document keeps a comment's time as written, and
//! its UTC time beside it when it knows it (`w16cex:dateUtc`, which WordCraft writes for the
//! comments it stamps). The Comments pane and the balloons show it in local time, as Word does.
/// Minutes to add to UTC for local time at Unix time `secs` (east of Greenwich is positive).
fn offset_minutes(secs: i64) -> i32 {
#[cfg(not(target_arch = "wasm32"))]
let minutes = {
use chrono::{Local, Offset, TimeZone};
Local.timestamp_opt(secs, 0).earliest().map_or(0, |t| t.offset().fix().local_minus_utc() / 60)
};
// The browser knows the zone: `getTimezoneOffset` is UTC minus local time, in minutes.
#[cfg(target_arch = "wasm32")]
let minutes = {
let d = js_sys::Date::new_0();
let offset = if d.set_time(secs as f64 * 1000.0).is_finite() { d.get_timezone_offset() } else { 0.0 };
if offset.is_finite() { -(offset as i32) } else { 0 }
};
// Real zones are within a day of UTC.
minutes.clamp(-24 * 60, 24 * 60)
}
/// A comment's time for the pane and the balloons: `YYYY-MM-DD HH:MM` in local time. The UTC
/// time (`date_utc`, Word's `w16cex:dateUtc`) wins, else `date`, converted when it names an
/// offset (`±HH:MM`) and shown as written when it doesn't. A `Z` on `date` alone doesn't count:
/// Word writes local time there with a `Z` it doesn't mean, and files from before `dateUtc` have
/// nothing else. Empty when neither is a date.
pub(crate) fn comment_time(c: &wordcraft_doc::Comment) -> String {
stamp(&c.date, c.date_utc.as_deref(), offset_minutes)
}
fn stamp(date: &str, date_utc: Option<&str>, offset: impl Fn(i64) -> i32) -> String {
let utc = date_utc.and_then(parse).filter(|t| t.timed).map(|t| Time { zone: Some(t.zone.unwrap_or(0)), ..t });
let local_date = || parse(date).map(|t| if date.trim_end().ends_with(['Z', 'z']) { Time { zone: None, ..t } } else { t });
let Some(t) = utc.or_else(local_date) else { return String::new() };
// A date without a time of day stays the date it says.
let local = match t.zone.filter(|_| t.timed) {
Some(zone) => {
let utc = t.secs - i64::from(zone) * 60;
utc + i64::from(offset(utc)) * 60
}
None => t.secs,
};
let (y, m, d) = wordcraft_engine::cmd::civil_from_days(local.div_euclid(86_400));
if !t.timed {
return format!("{y:04}-{m:02}-{d:02}");
}
let s = local.rem_euclid(86_400);
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", s / 3600, s / 60 % 60)
}
/// A date and time as a comment stores it.
#[derive(Clone, Copy)]
struct Time {
/// The wall time as if it were UTC, in seconds since the Unix epoch.
secs: i64,
/// The zone's offset from UTC in minutes, when the text names one.
zone: Option<i32>,
/// It has a time of day, not just a date.
timed: bool,
}
/// `YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM]`, else None.
fn parse(iso: &str) -> Option<Time> {
let iso = iso.trim();
let num = |a: usize, b: usize| -> Option<u32> {
let s = iso.get(a..b)?;
if s.bytes().all(|c| c.is_ascii_digit()) { s.parse().ok() } else { None }
};
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
if iso.get(4..5) != Some("-") || iso.get(7..8) != Some("-") || !(1..=12).contains(&mo) || d == 0 || d > days_in_month(y, mo) || y == 0 {
return None;
}
let mut secs = days_from_civil(i64::from(y), mo, d) * 86_400;
let mut rest = iso.get(10..)?;
let mut zone = None;
let mut timed = false;
if let Some(time) = rest.strip_prefix('T').or_else(|| rest.strip_prefix(' ')) {
timed = true;
let part = |a: usize, b: usize| time.get(a..b).filter(|s| s.bytes().all(|c| c.is_ascii_digit())).and_then(|s| s.parse::<u32>().ok());
let (h, mi) = (part(0, 2)?, part(3, 5)?);
if time.get(2..3) != Some(":") || h > 23 || mi > 59 {
return None;
}
let mut end = 5;
let mut s = 0;
if time.get(5..6) == Some(":") {
s = part(6, 8).filter(|s| *s <= 59)?;
end = 8;
// Fractions of a second.
if time.get(8..9) == Some(".") {
end = 9 + time.get(9..)?.bytes().take_while(u8::is_ascii_digit).count();
}
}
secs += i64::from(h * 3600 + mi * 60 + s);
rest = time.get(end..)?;
}
match rest {
"" => {}
"Z" | "z" => zone = Some(0),
_ => {
let sign = match rest.get(0..1) {
Some("+") => 1,
Some("-") => -1,
_ => return None,
};
let digits: String = rest.get(1..)?.chars().filter(|c| *c != ':').collect();
if digits.len() != 4 || !digits.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
let (h, m) = (digits.get(0..2)?.parse::<i32>().ok()?, digits.get(2..4)?.parse::<i32>().ok()?);
if h > 18 || m > 59 {
return None;
}
zone = Some(sign * (h * 60 + m));
}
}
Some(Time { secs, zone, timed })
}
/// Days in month `m` (1–12) of year `y`, proleptic Gregorian.
fn days_in_month(y: u32, m: u32) -> u32 {
match m {
2 if y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400)) => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
/// Days since 1970-01-01 of a proleptic Gregorian date (the inverse of
/// [`wordcraft_engine::cmd::civil_from_days`]).
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = i64::from((m + 9) % 12);
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_round_trip() {
for days in [-800_000, -1, 0, 1, 19_000, 20_376, 2_932_896] {
let (y, m, d) = wordcraft_engine::cmd::civil_from_days(days);
assert_eq!(days_from_civil(y, m, d), days);
}
assert_eq!(days_from_civil(2026, 10, 12), 20_738);
}
/// WordCraft's own comments carry their UTC time: shown in the user's zone, across a date
/// line too.
#[test]
fn utc_times_show_in_local_time() {
let toronto = |_: i64| -4 * 60;
let tokyo = |_: i64| 9 * 60;
let ours = |d: &str, offset: fn(i64) -> i32| stamp(d, Some(d), offset);
assert_eq!(ours("2026-10-12T04:44:31Z", toronto), "2026-10-12 00:44");
assert_eq!(ours("2026-10-12T02:05:00Z", toronto), "2026-10-11 22:05");
assert_eq!(ours("2026-10-12T20:30:00Z", tokyo), "2026-10-13 05:30");
assert_eq!(ours("2026-10-12T04:44:31Z", |_| 0), "2026-10-12 04:44");
// A zone in the text is honoured, and fractions of a second are fine.
assert_eq!(stamp("2026-10-12T09:00:00+05:30", None, |_| 0), "2026-10-12 03:30");
assert_eq!(stamp("2026-10-12T09:00:00.125-0200", None, |_| 0), "2026-10-12 11:00");
assert_eq!(ours("2026-10-12T04:44Z", toronto), "2026-10-12 00:44");
}
/// A Word file's `date` is local time (often with a Z it doesn't mean) and its `dateUtc` the real
/// time: the real time wins. A time without a zone shows as written.
#[test]
fn word_times_prefer_the_utc_one() {
let berlin = |_: i64| 2 * 60;
assert_eq!(stamp("2026-10-12T10:00:00Z", Some("2026-10-12T08:00:00Z"), berlin), "2026-10-12 10:00");
// Without `dateUtc` (older files), the local time shows as written, `Z` or not.
assert_eq!(stamp("2026-10-12T10:00:00Z", None, berlin), "2026-10-12 10:00");
assert_eq!(stamp("2026-10-12T10:00:00", None, berlin), "2026-10-12 10:00");
assert_eq!(stamp("2026-10-12T10:00:00", Some("not a date"), berlin), "2026-10-12 10:00");
// A date alone stays a date.
assert_eq!(stamp("2026-10-12", None, berlin), "2026-10-12");
assert_eq!(stamp("2026-10-12Z", Some("2026-10-12"), |_| -4 * 60), "2026-10-12");
}
/// Whatever a file holds, a stamp is a date or nothing, never a panic.
#[test]
fn hostile_times_show_nothing() {
for bad in [
"",
"yesterday",
"2026-13-01T00:00:00Z",
"2026-10-12T25:00:00Z",
"2026-10-12T10:61Z",
"2026-10-12T10:00:00+99:00",
"2026-10-12T10:00:00+1",
"2026-1O-12",
"0000-01-01",
"2026-10-12",
"2026-10-12T10:00:00ZZ",
"2026-10-12T1",
"+2026-10-12",
"2026-02-30T10:00:00Z",
"2026-02-29",
"2026-10-12T10:00:99Z",
] {
assert_eq!(stamp(bad, None, |_| 0), "", "{bad:?}");
}
assert_eq!(stamp("2024-02-29", None, |_| 0), "2024-02-29");
assert_eq!(stamp("9999-12-31T23:59:59Z", Some("9999-12-31T23:59:59Z"), |_| 24 * 60), "10000-01-01 23:59");
assert_eq!(stamp("0001-01-01T00:00:00Z", Some("0001-01-01T00:00:00Z"), |_| -24 * 60), "0000-12-31 00:00");
}
#[test]
fn the_local_offset_is_a_real_zone() {
let m = offset_minutes(1_760_000_000);
assert!((-24 * 60..=24 * 60).contains(&m), "{m}");
}
}
+24 -1
View File
@@ -278,7 +278,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
sep(ui, t);
let hl = app.canvas.last_highlight.clone();
widgets::split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| {
let (icon, sw, color) = highlight_button_appearance(&hl);
widgets::split(ui, app, icon, "Text Highlight Color", "format.highlight", json!({"color": color}), false, sw, |ui, app| {
highlight_menu(ui, app);
});
let fc = app.canvas.last_font_color.clone();
@@ -303,6 +304,13 @@ fn sep(ui: &mut egui::Ui, t: &Tokens) {
p.vline(r.center().x, r.y_range().shrink(4.0), egui::Stroke::new(1.0, t.border));
}
/// The split-button icon and colour bar for the remembered highlight choice.
pub(crate) fn highlight_button_appearance(name: &str) -> (&'static str, Option<egui::Color32>, &'static str) {
let highlight = wordcraft_doc::props::Highlight::from_ooxml(name);
let icon = if highlight == wordcraft_doc::props::Highlight::None { "highlightNoColor" } else { "highlight" };
(icon, highlight.rgb().map(crate::theme::c32), highlight.ooxml())
}
/// Highlight colour grid and No Color, shared by the ribbon and the mini toolbar. The pick,
/// No Color too, becomes the split button's colour, as in Word: after No Color a click on the
/// button removes highlighting (#2105).
@@ -348,6 +356,13 @@ fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
mod tests {
use super::*;
#[test]
fn highlight_button_appearance_follows_the_picked_colour() {
assert_eq!(highlight_button_appearance("cyan"), ("highlight", Some(egui::Color32::from_rgb(0, 255, 255)), "cyan"));
assert_eq!(highlight_button_appearance("none"), ("highlightNoColor", None, "none"));
assert_eq!(highlight_button_appearance("unknown"), ("highlightNoColor", None, "none"));
}
fn vp() -> Rect {
Rect::from_min_size(pos2(100.0, 50.0), Vec2::new(1200.0, 700.0))
}
@@ -853,6 +868,14 @@ mod tests {
let arrow = highlight_arrow(&h);
click_at(&mut h, pos2(arrow.min.x - 12.0, arrow.center().y));
assert_eq!(format_state(&mut h).get("highlight"), Some(&Value::Null), "the button applies No Color, not yellow again");
// A stale or corrupted remembered value looks like No Color and must act like it too.
h.state_mut().canvas.last_highlight = "unknown".into();
h.state_mut().run("format.highlight", json!({"color": "yellow"})).unwrap();
steps(&mut h);
let arrow = highlight_arrow(&h);
click_at(&mut h, pos2(arrow.min.x - 12.0, arrow.center().y));
assert_eq!(format_state(&mut h).get("highlight"), Some(&Value::Null), "an unknown remembered colour is normalized to No Color");
}
/// #3039: picking Automatic from the bar's font-colour menu hands the keyboard back to the
+47 -8
View File
@@ -122,12 +122,22 @@ fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
}
}
/// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected
/// and deleted, not moved or resized (they aren't written back on save yet), so they get no
/// handles, no move cursor and no drag.
/// Whether the object under `hit` keeps its place and size: a chart or diagram WordCraft has no
/// model for can be selected and deleted, not moved or resized, so it gets no handles, no move
/// cursor and no drag.
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
let pos = hit.pos();
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. }))
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
}
/// Whether the object under `hit` is a chart or SmartArt that WordCraft can edit: it takes
/// resize handles and is laid out again at the new size, but it can't be moved or turned.
fn size_only(app: &WordApp, hit: &ObjectHit) -> bool {
let pos = hit.pos();
matches!(
app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)),
Some(InlineObject::Graphic { graphic, .. }) if graphic.chart.is_some() || graphic.smart_art.is_some()
)
}
/// The story of the selected text box.
@@ -294,7 +304,8 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
None => return true,
};
// Objects in table cells can be selected and resized, not moved; charts neither.
if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) {
let size_only = size_only(app, &object);
if !fixed(app, &object) && (grab != Grab::Move || (movable(app, &object) && !size_only)) {
let copy = grab == Grab::Move && ui.input(|i| i.modifiers.command);
app.canvas.obj_drag = Some(ObjectDrag {
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
@@ -432,7 +443,7 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po
let story = active_story(app, layout);
let handle = active(app, layout).filter(|(o, _)| o.story == story && !fixed(app, o)).and_then(|(o, _)| {
let f = screen(pages, scale, o.page, o.rect)?;
if frame::rotate_handle_at(f, o.spin.deg, at) {
if !size_only(app, &o) && frame::rotate_handle_at(f, o.spin.deg, at) {
return Some((o, Grab::Rotate));
}
frame::handle_at(f, o.spin.deg, at).map(|h| (o, Grab::Resize(h)))
@@ -522,6 +533,11 @@ fn drop(app: &mut WordApp, d: &ObjectDrag) {
return;
}
let r = d.drag.rect;
// A chart or SmartArt takes a new size, never a new place.
if matches!(d.drag.grab, Grab::Resize(_)) && size_only(app, &d.object) {
let _ = app.run("picture.size", json!({"width": r.w, "height": r.h, "lockAspect": false}));
return;
}
let params = match d.drag.grab {
Grab::Rotate => {
let _ = app.run("arrange.rotation", json!({"degrees": d.drag.deg}));
@@ -624,7 +640,7 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
// Handles where it can be resized and turned (body and header/footer objects,
// not charts): else a plain frame.
let handles = selectable_story(app, o.story) && !fixed(app, &o);
frame::paint(painter, t, f, o.spin.deg, editing, handles, handles);
frame::paint(painter, t, f, o.spin.deg, editing, handles, handles && !size_only(app, &o));
}
}
@@ -693,7 +709,13 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
if connector_end_at(app, layout, pages, scale, at).is_some() {
return Some(egui::CursorIcon::Crosshair);
}
grab_at(app, layout, pages, scale, at).map(|(o, g)| if fixed(app, &o) { egui::CursorIcon::Default } else { g.cursor() })
grab_at(app, layout, pages, scale, at).map(|(o, g)| hover_cursor(app, &o, g))
}
/// The cursor over part `g` of object `o`: none of a drag for an object that won't move there (a
/// fixed graphic, or the body of a chart or SmartArt, which only resizes).
fn hover_cursor(app: &WordApp, o: &ObjectHit, g: Grab) -> egui::CursorIcon {
if fixed(app, o) || (g == Grab::Move && size_only(app, o)) { egui::CursorIcon::Default } else { g.cursor() }
}
/// Keys for a selected object: arrows nudge a floating one; typing or Enter goes into a text box.
@@ -767,6 +789,23 @@ mod tests {
let (app, hit) = app_with(shape);
assert!(!fixed(&app, &hit));
}
#[test]
fn editable_smart_art_takes_resize_handles_but_not_a_move() {
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
app.run("insert.smartArt", json!({"layout": "pyramid", "items": ["a", "b"]})).unwrap();
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
assert!(!fixed(&app, &hit));
assert!(size_only(&app, &hit));
// Its body shows no move cursor; its handles show theirs.
assert_eq!(hover_cursor(&app, &hit, Grab::Move), egui::CursorIcon::Default);
assert_ne!(hover_cursor(&app, &hit, Grab::Resize(frame::Handle { x: 1, y: 0 })), egui::CursorIcon::Default);
let (w0, h0) = (hit.rect.w, hit.rect.h);
assert!(app.run("arrange.bounds", json!({"width": w0 / 2.0, "height": h0 / 2.0})).is_err());
app.run("picture.size", json!({"width": w0 / 2.0, "height": h0 / 2.0, "lockAspect": false})).unwrap();
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
assert!((hit.rect.w - w0 / 2.0).abs() < 1.0);
}
/// Alignment guides: dragging a floating shape to within a few points of the page's centre
/// snaps its centre exactly onto it and shows the guide while dragging; the drop lands there,
/// and the guides go. Holding Alt drags freely.
+2 -15
View File
@@ -446,6 +446,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
for c in list.as_array().cloned().unwrap_or_default() {
let id = c.get("id").and_then(Value::as_u64).unwrap_or(0);
let reply = c.get("parent").is_some_and(|p| !p.is_null());
let when = u32::try_from(id).ok().and_then(|i| app.session.doc.comments.get(&i)).map(crate::local_time::comment_time).unwrap_or_default();
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| {
ui.set_width(ui.available_width());
if reply {
@@ -453,7 +454,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new(c.get("author").and_then(Value::as_str).unwrap_or("")).font(semibold(12.0)));
ui.label(egui::RichText::new(comment_stamp(c.get("date").and_then(Value::as_str).unwrap_or(""))).small().weak());
ui.label(egui::RichText::new(when).small().weak());
if c.get("resolved").and_then(Value::as_bool).unwrap_or(false) {
ui.label(egui::RichText::new(tl!("Resolved")).small().color(t.green));
}
@@ -886,11 +887,6 @@ fn trim_num(x: f64) -> String {
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// `2026-10-12T04:44:00Z` → `2026-10-12 04:44` (date and time, no seconds) for comment cards.
fn comment_stamp(iso: &str) -> String {
iso.chars().take(16).map(|c| if c == 'T' { ' ' } else { c }).collect()
}
/// `dotDash` → `Dot dash` (enum values from the document model).
fn camel_words(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
@@ -909,15 +905,6 @@ fn camel_words(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::comment_stamp;
#[test]
fn comment_stamp_keeps_date_and_time() {
assert_eq!(comment_stamp("2026-10-12T04:44:31Z"), "2026-10-12 04:44");
assert_eq!(comment_stamp("2026-10-12"), "2026-10-12");
assert_eq!(comment_stamp(""), "");
}
/// #1902: Clipboard Delete returns focus to the document like paste does.
#[test]
#[cfg(not(target_arch = "wasm32"))]
+79
View File
@@ -56,6 +56,47 @@ pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u
Ok(png)
}
/// Whether a paste takes a `width` × `height` picture: the sides and pixels [`png_from_rgba`]
/// allows.
fn pastes(width: u32, height: u32) -> bool {
let (w, h) = (width as usize, height as usize);
w <= MAX_SIDE && h <= MAX_SIDE && w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS)
}
fn too_large_to_paste(width: u32, height: u32) -> ClipboardPicture {
ClipboardPicture::Unreadable(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"))
}
/// An encoded picture the host read from the clipboard (the Windows "PNG" format), refused from
/// its header when a paste can't take its size, before anything decodes it (#3623).
pub fn encoded_picture(bytes: Vec<u8>) -> ClipboardPicture {
match wordcraft_geom::picture::probe(&bytes).map(|p| (p.width, p.height)) {
Ok((w, h)) if pastes(w, h) => ClipboardPicture::Encoded(bytes),
Ok((w, h)) => too_large_to_paste(w, h),
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
}
}
/// A DIB from the Windows clipboard (`CF_DIBV5`, `CF_DIB`) as pixels: its size is checked from
/// its header, then it is decoded within the decode budget. arboard decoded it with no limits
/// before anything was checked (#3623).
pub fn dib_picture(dib: Vec<u8>) -> ClipboardPicture {
let p = match wordcraft_geom::picture::probe_dib(dib) {
Ok(p) => p,
Err(e) => return ClipboardPicture::Unreadable(e.to_string()),
};
if !pastes(p.width, p.height) {
return too_large_to_paste(p.width, p.height);
}
match p.decode(wordcraft_geom::MAX_DECODED_BYTES) {
Ok(img) => {
let rgba = img.into_rgba8();
ClipboardPicture::Pixels { width: rgba.width() as usize, height: rgba.height() as usize, rgba: rgba.into_raw() }
}
Err(e) => ClipboardPicture::Unreadable(e.to_string()),
}
}
fn is_picture_file(path: &Path) -> bool {
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
@@ -184,6 +225,44 @@ mod tests {
assert!(!names_picture_files("see /tmp/b.png") && !names_picture_files("notes.txt") && !names_picture_files(""));
}
/// A BITMAPV5HEADER DIB of 32-bit `BI_RGB` pixels (bottom-up) with an alpha mask, as Windows
/// programs put one on the clipboard.
fn dib(w: i32, h: i32, pixels: &[[u8; 4]]) -> Vec<u8> {
let mut d = vec![0u8; 124];
d[0..4].copy_from_slice(&124u32.to_le_bytes());
d[4..8].copy_from_slice(&w.to_le_bytes());
d[8..12].copy_from_slice(&h.to_le_bytes());
d[12..14].copy_from_slice(&1u16.to_le_bytes());
d[14..16].copy_from_slice(&32u16.to_le_bytes());
d[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes());
d.extend(pixels.iter().flatten());
d
}
/// #3623: what the Windows clipboard holds is checked from its header before it is decoded.
#[test]
fn windows_clipboard_pictures_are_checked_from_their_headers() {
let mut png = Vec::new();
image::RgbaImage::new(3, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(matches!(encoded_picture(png.clone()), ClipboardPicture::Encoded(b) if b == png));
let mut wide = Vec::new();
image::GrayImage::new(20_000, 1).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
assert!(matches!(encoded_picture(wide), ClipboardPicture::Unreadable(e) if e.contains("too large")));
assert!(matches!(encoded_picture(b"not a picture".to_vec()), ClipboardPicture::Unreadable(_)));
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
let ClipboardPicture::Pixels { width, height, rgba } = dib_picture(dib(2, 1, &[[0, 0, 255, 128], [255, 0, 0, 255]])) else {
panic!("pixels")
};
assert_eq!((width, height, rgba), (2, 1, vec![255, 0, 0, 128, 0, 0, 255, 255]));
// A header claiming 60000 × 60000 over no pixels is refused unread.
assert!(matches!(dib_picture(dib(60_000, 60_000, &[])), ClipboardPicture::Unreadable(e) if e.contains("too large")));
assert!(matches!(dib_picture(vec![0; 10]), ClipboardPicture::Unreadable(_)));
// And a DIB pastes as a picture.
let mut a = app_with(|| Some(dib_picture(dib(2, 2, &[[9, 9, 9, 255]; 4]))));
a.run("edit.paste", json!({})).unwrap();
assert_eq!(pictures(&mut a), 1);
}
/// Hostile clipboard sizes are refused without inserting anything (or allocating for them).
#[test]
fn hostile_clipboard_pictures_are_refused() {
+29 -4
View File
@@ -75,6 +75,20 @@ fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) {
}
});
ui.separator();
use wordcraft_engine::cmd::view::ReadPageColor;
let colors =
[(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "Inverse", "inverse")];
let current = app.session.view.read_page_color;
let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1);
ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| {
for (color, name, value) in colors {
if ui.selectable_label(current == color, tl!(name)).clicked() {
let _ = app.run("view.readPageColor", json!({"value": value}));
ui.close();
}
}
});
ui.separator();
// Headings to jump to, in the Navigation pane beside the columns (Outline view
// is an editing layout, so it isn't offered here).
if ui.button(tl!("Navigation Pane")).clicked() {
@@ -1156,15 +1170,16 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
mi(ui, app, "Double Strikethrough", "format.doubleStrikethrough", json!({}));
});
let hl = app.canvas.last_highlight.clone();
let (icon, sw, color) = crate::mini_toolbar::highlight_button_appearance(&hl);
split(
ui,
app,
"highlight",
icon,
"Text Highlight Color",
"format.highlight",
json!({"color": hl}),
json!({"color": color}),
false,
None,
sw,
crate::mini_toolbar::highlight_menu,
);
let fc = app.canvas.last_font_color.clone();
@@ -2182,12 +2197,16 @@ fn review(app: &mut WordApp, ui: &mut Ui) {
});
group(ui, "Changes", None, app, |ui, app| {
menu_button(ui, app, "accept", Some("Accept"), "Accept", true, "review.accept", |ui, app| {
mi(ui, app, "Accept and Move to Next", "review.accept", json!({"next": true}));
mi(ui, app, "Accept This Change", "review.accept", json!({}));
mi(ui, app, "Accept All Changes", "review.acceptAll", json!({}));
mi(ui, app, "Accept All Changes and Stop Tracking", "review.acceptAll", json!({"stopTracking": true}));
});
menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| {
mi(ui, app, "Reject and Move to Next", "review.reject", json!({"next": true}));
mi(ui, app, "Reject Change", "review.reject", json!({}));
mi(ui, app, "Reject All Changes", "review.rejectAll", json!({}));
mi(ui, app, "Reject All Changes and Stop Tracking", "review.rejectAll", json!({"stopTracking": true}));
});
stack(ui, |ui| {
small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false);
@@ -2458,6 +2477,7 @@ fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Reset", None, app, |ui, app| {
big(ui, app, "resetGraphic", "Reset\nGraphic", "smartArt.reset", json!({}), false);
});
graphic_size(ui, app);
}
/// Chart Design: the selected chart's elements (title, legend, data labels), its data and type,
@@ -2492,6 +2512,11 @@ fn chart_design(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Type", None, app, |ui, app| {
big(ui, app, "changeChartType", "Change\nChart Type", "chart.type", json!({}), false);
});
graphic_size(ui, app);
}
/// Chart Design and SmartArt Design › Size: the selected graphic's height and width.
fn graphic_size(ui: &mut Ui, app: &mut WordApp) {
group(ui, "Size", None, app, |ui, app| {
let (w0, h0) =
wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0));
@@ -2501,7 +2526,7 @@ fn chart_design(app: &mut WordApp, ui: &mut Ui) {
ui.label(egui::RichText::new(tl!(label)).small());
let mut v = v0;
// The range bounds what is typed or dragged; a size outside it is shown as it
// is (clamped, egui would report a change and resize the chart unasked).
// is (clamped, egui would report a change and resize the graphic unasked).
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(36.0..=2000.0).clamp_existing_to_range(false).suffix(" pt"));
if r.changed() && v != v0 {
if r.dragged() && !r.drag_started() {
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more