mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 09:11:10 +00:00
Merge branch 'main' into feat/autoformat-bold-italic
This commit is contained in:
commit
e8015c1e75
101 files changed
+6903
-487
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -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" }
|
||||
+35
-1
@@ -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-24 · **Change:** trivial (progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read-only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: 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 Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -137,16 +139,29 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
## Progress log
|
||||
|
||||
- 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-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: 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-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.
|
||||
|
||||
@@ -171,7 +186,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 |
|
||||
@@ -596,15 +616,29 @@ 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-24 | trivial | Progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting |
|
||||
| 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-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) |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(®ion) };
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -176,5 +176,8 @@ display_name = "Mulai"
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
|
||||
[people.matstrange]
|
||||
sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")],
|
||||
);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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 & 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`.
|
||||
|
||||
@@ -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, .. } = ¶s(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, .. } = ¶s(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>
|
||||
|
||||
@@ -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 <- 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>");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -141,9 +141,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 +181,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 +384,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 +406,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 +540,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,6 +576,13 @@ 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)
|
||||
|
||||
@@ -243,14 +243,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 +517,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 })
|
||||
}
|
||||
|
||||
|
||||
+169
-17
@@ -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();
|
||||
@@ -244,6 +247,7 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
|| text.len() < 3
|
||||
|| !text.chars().all(|x| x == c)
|
||||
|| 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();
|
||||
@@ -270,6 +274,7 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
|| at.path.0.len() != 1
|
||||
|| at.off != text.len()
|
||||
|| 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);
|
||||
@@ -638,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;
|
||||
@@ -668,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);
|
||||
@@ -679,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(¶.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(¶.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;
|
||||
@@ -758,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
|
||||
|
||||
@@ -106,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.
|
||||
@@ -142,6 +150,8 @@ impl Default for AutoCorrectPrefs {
|
||||
bullets: true,
|
||||
numbering: true,
|
||||
border_lines: true,
|
||||
tab_indent: false,
|
||||
heading_styles: false,
|
||||
entries: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
@@ -332,6 +342,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
("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),
|
||||
] {
|
||||
@@ -406,6 +418,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"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<_>>(),
|
||||
@@ -443,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, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "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)
|
||||
@@ -472,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
|
||||
@@ -879,7 +905,9 @@ 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 mut 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(());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+447
-1
@@ -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.
|
||||
@@ -2873,6 +3070,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 +3320,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 +5578,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() {
|
||||
@@ -8753,6 +9176,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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -489,6 +493,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 ");
|
||||
@@ -736,6 +759,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");
|
||||
@@ -768,6 +856,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");
|
||||
@@ -789,6 +1026,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}")] {
|
||||
@@ -967,6 +1316,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.
|
||||
@@ -2679,3 +3042,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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -1999,11 +1999,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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 `}`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "image")]
|
||||
pub mod picture;
|
||||
mod region;
|
||||
pub use region::Region;
|
||||
mod pixels;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-24
@@ -700,6 +700,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,
|
||||
@@ -1274,6 +1279,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.
|
||||
@@ -1288,10 +1296,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);
|
||||
@@ -1317,7 +1328,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
|
||||
@@ -1358,11 +1369,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)
|
||||
}
|
||||
@@ -2301,6 +2312,7 @@ fn layout_pass(
|
||||
});
|
||||
let mut ctx = Ctx {
|
||||
doc,
|
||||
sections: doc.sections(),
|
||||
cache,
|
||||
opts,
|
||||
hide_deleted: opts.hides_deleted(StoryRef::Body),
|
||||
@@ -3097,8 +3109,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
|
||||
@@ -3111,14 +3124,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`
|
||||
@@ -3169,7 +3183,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)),
|
||||
@@ -3189,19 +3203,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,
|
||||
@@ -3209,6 +3223,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()
|
||||
@@ -3391,12 +3406,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(§ions, 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) {
|
||||
@@ -3408,7 +3422,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(§ions, 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);
|
||||
@@ -3902,7 +3916,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
|
||||
@@ -4510,12 +4540,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(§ions, 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) {
|
||||
@@ -4528,7 +4557,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(§ions, 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);
|
||||
@@ -4599,7 +4628,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;
|
||||
}
|
||||
@@ -4916,6 +4945,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;
|
||||
@@ -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
@@ -849,6 +849,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() {
|
||||
// n square-wrapped shapes, each anchored in its own hidden run (bold alternating, so runs do not
|
||||
@@ -5009,6 +5029,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).
|
||||
@@ -8881,15 +8974,29 @@ fn space_after_stays_clear_of_the_footnote_separator() {
|
||||
#[test]
|
||||
fn space_after_may_overflow_the_bottom_margin_without_notes() {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -569,3 +569,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) = (¶.lines[*l0], ¶.lines[*l1 - 1], ¶.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");
|
||||
}
|
||||
}
|
||||
@@ -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 = ¶.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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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})))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
@@ -460,12 +511,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 {
|
||||
@@ -541,6 +604,7 @@ impl Default for RenderOptions {
|
||||
text_darkening: false,
|
||||
dark: false,
|
||||
dark_paper: DARK_PAPER,
|
||||
read_paper: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,9 +631,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);
|
||||
@@ -602,7 +668,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;
|
||||
}
|
||||
@@ -789,6 +855,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, .. } => {
|
||||
@@ -978,7 +1048,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;
|
||||
@@ -992,7 +1062,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);
|
||||
}
|
||||
}
|
||||
@@ -1026,7 +1096,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);
|
||||
@@ -1082,6 +1152,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);
|
||||
@@ -1139,6 +1210,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 {
|
||||
@@ -1354,6 +1434,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];
|
||||
@@ -1566,6 +1647,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> {
|
||||
@@ -1630,6 +1749,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");
|
||||
@@ -2239,6 +2419,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::{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -780,6 +780,7 @@ fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
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)
|
||||
@@ -1059,7 +1060,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
|
||||
@@ -1358,7 +1364,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.
|
||||
@@ -1941,7 +1955,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())
|
||||
@@ -2127,22 +2141,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) {
|
||||
@@ -2165,9 +2183,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));
|
||||
}
|
||||
@@ -2339,8 +2357,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 {
|
||||
@@ -2354,6 +2373,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));
|
||||
@@ -2361,10 +2387,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) {
|
||||
@@ -3586,6 +3614,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
|
||||
@@ -4061,6 +4096,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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,6 +354,8 @@ impl AutoCorrectForm {
|
||||
"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,
|
||||
})
|
||||
@@ -403,6 +405,9 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+167
-7
@@ -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 {
|
||||
@@ -6908,6 +6954,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]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
@@ -2458,6 +2473,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 +2508,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 +2522,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() {
|
||||
|
||||
@@ -571,6 +571,10 @@ pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, system_
|
||||
// The same static bytes the document fonts use: one copy in the binary.
|
||||
add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes));
|
||||
}
|
||||
// Georgian has no glyphs in Inter, Source Sans or the CJK faces: the craft-fonts Geor face.
|
||||
for f in wordcraft_fonts::georgian_fonts() {
|
||||
add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes));
|
||||
}
|
||||
// The embedded faces leave interface characters without a glyph (a build without craft-fonts,
|
||||
// without its Chinese face, #241, or with faces lacking catalog characters, #486): an
|
||||
// installed CJK font after them, so the menus don't show boxes or `?`.
|
||||
|
||||
@@ -320,7 +320,9 @@ pub fn split(
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
if let Some(sw) = swatch {
|
||||
let sw = if on { sw } else { t.text_disabled };
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(r.min.x + 4.0, r.max.y - 5.0), pos2(r.max.x - 4.0, r.max.y - 2.0)), 0.0, sw);
|
||||
let bar = Rect::from_min_max(pos2(r.min.x + 4.0, r.max.y - 5.0), pos2(r.max.x - 4.0, r.max.y - 2.0));
|
||||
ui.painter().rect_filled(bar, 0.0, sw);
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(0.5, t.border), egui::StrokeKind::Inside);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(DROPDOWN, DROPDOWN)), "dropdown", c, a);
|
||||
let resp = tooltip(app, resp, tip, id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance Line truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeLine truncated
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -101,6 +101,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped |
|
||||
| Legacy font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint` in `w:rPr`) | ✅ | ✅ | Drawn on screen and in PDFs as Word's PDFs show them (#2534): hollow outlined letters; a sharp grey copy down-right (shadow, emboss) or up-left (imprint); WordArt effects on the same run win; tracked insertions and deletions keep them. Not drawn on colour emoji, or on top-to-bottom text with upright East Asian characters. Emboss and imprint are approximations (no highlight edge) |
|
||||
| OpenType ligatures (`w14:ligatures` in `w:rPr`: runs, styles, document defaults) | ✅ | ✅ | All 16 values; an unknown value is ignored; nothing is written when the document has none |
|
||||
| DrawingML outline caps (`a:ln/@cap`) | ✅ | ✅ | Flat/default, square (`sq`) and round (`rnd`) imported, saved and applied in raster/PDF; missing or unknown caps are flat. Synthetic read/save, endpoint-pixel and PDF-content tests (cu20we); theme inheritance unverified. |
|
||||
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); `flowChartOr` draws a circle with horizontal and vertical spokes; rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles), including custom-geometry paths (#416); fills with transparency (`a:alpha`) and gradients (`a:gradFill`: up to 10 stops each with its transparency, `a:lin` angle and `scaled`, `a:path` circle/rect/shape with `fillToRect`, `tileRect`, `rotWithShape`, `flip`) read and written as stated, also through the theme's fill styles (`a:fillRef` 1–999 `a:fillStyleLst`, 1001+ `a:bgFillStyleLst`, `phClr` in the reference's colour; written as the resolved fill), outline gradients kept (drawn in their middle stop) (#1500). Drawn: linear and circular; rectangular and shape-following paths drawn circular; `tileRect`/`flip` not drawn; pattern fills drawn and saved as their foreground colour. Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet |
|
||||
| Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight |
|
||||
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened; members' `a:grpFill` takes their group's fill, and a canvas background (`wpc:bg`) may be a gradient or transparent (#1500). The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
|
||||
@@ -250,10 +251,14 @@ Imported text fields use the first nonempty cached result run's formatting on th
|
||||
|
||||
## Revision history
|
||||
|
||||
> 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.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) |
|
||||
| 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) |
|
||||
| 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) |
|
||||
| 2026-10-21 | trivial | HTML export writes ruby (furigana, phonetic guides) as `<ruby>base<rt>reading</rt></ruby>`, in the base text's run formatting, and HTML import reads `<ruby>`/`<rb>`/`<rt>` back into one ruby object: `<rp>` fallback parentheses and the tags are not text, an unclosed element ends at the next block, and malformed ruby keeps its text. HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) |
|
||||
| 2026-10-21 | trivial | RTF import (#1653): the East Asian multibyte code pages are decoded — 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC/windows-949 (Korean) and 950 Big5 (Traditional Chinese), named by `\ansicpg`, a font's `\cpg` or `\fcharset128`/`129`/`134`/`136` (and the OEM charset 255 as 437), escaped (`\'hh`) or raw. Bytes are decoded as a run, so a lead byte and its trail byte decode together; a code page switch, a group end and the end of the document decode what is pending; invalid sequences read as U+FFFD and never lose the rest of the text. The decoder does no BOM sniffing, so a run that starts `EF BB BF` in GBK, Big5 or UHC stays text, and ASCII runs and picture hex no longer decode byte by byte. Single-byte code pages, `\uN?` escapes and their `\ucN` skips are unchanged |
|
||||
| 2026-10-21 | trivial | Tables: table style band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`) are written for our banded styles and read with zero kept; a style without them has no bands, as in Word, so banded rows shade the same rows in both (#3415) |
|
||||
|
||||
+20
-2
@@ -1,6 +1,7 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (#8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minLine truncated
|
||||
> **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.
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; 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 trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep theLine truncated
|
||||
|
||||
Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md).
|
||||
|
||||
@@ -16,6 +17,8 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees).
|
||||
Row table border exceptions (`w:tblPrEx/w:tblBorders`) are read, drawn and saved. Other row table exceptions, including widths and cell margins, remain unsupported.
|
||||
|
||||
Floating tables count exact contact with a line's bottom edge (#3977); shapes keep strict overlap. Original synthetic regressions cover the boundary and narrow-strip word skipping in modes 11/12/14/15, based on the reporter's Word measurements. Exact-spacing table checks use the specified height even when paragraph-mark metrics differ, including when a word skips a narrow strip. This narrows gap #4; wrapping earlier text around a later table remains #3790's scope.
|
||||
|
||||
An otherwise empty section at page start retains its line before continuous content. Shared-section page totals and wrapping earlier text around later floating tables remain open.
|
||||
|
||||
Consistent cell widths override stale table grid proportions when their totals agree. Conflicting widths, spans and percentages still need the shared-grid algorithm.
|
||||
@@ -54,7 +57,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
|
||||
|
||||
| # | Gap | B | Evidence | Impact | Hours | Doc |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), shape gradients and transparency (#1500), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), VML freeform rule lines in headers, and `flowChartOr` drawn with its spokes (#1644); explicit DrawingML outline dashes preserved | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), shape gradients and transparency (#1500), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), VML freeform rule lines in headers, and `flowChartOr` drawn with its spokes (#1644); explicit DrawingML outline dashes and flat/square/round caps preserved; paragraph-relative header/footer/cell/text-box float origins corrected (cu20we, synthetic regressions) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
@@ -122,9 +125,24 @@ 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.
|
||||
|
||||
> 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 floats in headers, footers, cells and text boxes now start above effective space before; synthetic layout regressions cover all four contexts. Framed paragraphs already used the pre-spacing position. No new native Word comparison; broader object-position parity remains open.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-22 | minor | #8 narrowed: Spanish proofing (es-ES, es-MX, es-419, es-UY spelling from the installed Hunspell dictionaries, Uruguayan `vos` forms, generic and opening-mark checks, #3360, #3553) |
|
||||
| 2026-10-25 | trivial | Text Highlight Color buttons show the remembered colour and no bar after No Color (#4087); no ranked gap or readiness estimate changes |
|
||||
| 2026-10-23 | trivial | A command that changes nothing adds no undo step, marks no unsaved changes and evicts nothing from the undo history; the redo stack survives it. No gap row (defect fix); estimates unchanged. |
|
||||
| 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: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged |
|
||||
| 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-22 | trivial | Gap 10: group connector rerouting preserves rotated/flipped free and unresolved endpoints under nonuniform scaling, with safe degenerate-dimension and display-size-cap handling; routing around shapes and readiness estimates unchanged |
|
||||
| 2026-10-21 | trivial | The ranked gap list held two rows each for #19, #20 and #21; the stale copies are gone, so each gap appears once, and the #20 row carries the measured figure (#3411) |
|
||||
| 2026-10-21 | trivial | #20 narrowed: a document with many relationships opens in the time its part takes (#3411) |
|
||||
|
||||
+21
-3
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously triviaLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** 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 trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; 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: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously Line truncated
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -61,7 +61,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 |
|
||||
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — |
|
||||
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); other overlap rules untested | 2–3 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 |
|
||||
| Nested tables | 🟡 | render; polish missing | 2–4 |
|
||||
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell | — |
|
||||
| Shape fills: gradients and transparency | 🟡 | #1500: linear (angle, `scaled`) and circular gradients and transparent fills drawn on screen, PNG and PDF (axial/radial shadings), soft edges fading the real fill; rectangular and shape-following path gradients drawn circular, `tileRect`/`flip` not drawn, outline gradients drawn in their middle colour, `rotWithShape="0"` drawn as turning with the shape | 2–4 |
|
||||
@@ -212,7 +212,15 @@ TOC refresh includes heading and outline paragraphs inside tables, including nes
|
||||
Explicit DrawingML preset and custom outline dashes retain their stroke-width units on import
|
||||
and save, in raster output and in PDF. Synthetic controls cover red rectangles, ellipses and
|
||||
connectors. Custom lists are capped at 64 pairs; painted spans are bounded to 0.01–20000 pt.
|
||||
Theme-inherited dash styles, line caps, ink and open freeform strokes are not covered by this change.
|
||||
Theme-inherited dash styles, ink and open freeform stroke dashes are not covered by the dash regressions.
|
||||
|
||||
### Outline caps (cu20we)
|
||||
|
||||
Explicit DrawingML flat, square and round caps survive import/save and reach both preset-shape
|
||||
and path strokes in the screen renderer and PDF export. Missing or unknown caps use flat ends.
|
||||
Raster endpoint/corner probes and uncompressed PDF content checks cover preset lines and
|
||||
connectors. VML end caps, including shapetype inheritance, are kept when saved as DrawingML.
|
||||
Native Word screenshot comparison and theme-inherited caps remain unverified.
|
||||
When content exceeds a preferred column width, AutoFit donors lose equal widths until they reach their content minima. Original synthetic Word probes cover donor reductions; cached field refresh and wrapping remain separate gaps.
|
||||
|
||||
Behind-text pictures in table cells participate in centre and bottom vertical alignment without increasing row flow height.
|
||||
@@ -237,8 +245,18 @@ Installed static fonts also resolve by their OpenType PostScript name (name ID 6
|
||||
|
||||
## 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.
|
||||
|
||||
> 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 floats in headers, footers, table cells and text boxes start above effective paragraph space before, consistent with the body layout path (cu20we). Synthetic regressions cover all four contexts; native Word comparison remains pending.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged |
|
||||
| 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-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged |
|
||||
| 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) |
|
||||
| 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) |
|
||||
|
||||
+5
-2
@@ -13,7 +13,10 @@ Claude Code: `claude mcp add wordcraft -- wordcraft-cli mcp`.
|
||||
|
||||
With `--connect`, the bridge sends the app's control key, which it reads from
|
||||
`WORDCRAFT_CONTROL_KEY` or from the key file the app writes at start. It sends the key only to
|
||||
loopback addresses; see [Keys](control-protocol.md#keys).
|
||||
loopback addresses; see [Keys](control-protocol.md#keys). The bridge serves even when the app
|
||||
isn't open yet: tool calls return "not reachable … will retry on each call" and succeed once the
|
||||
app's port opens (#4078), so a client that starts before WordCraft doesn't lose the server for
|
||||
its whole session.
|
||||
|
||||
## Scripts from the command line
|
||||
|
||||
@@ -108,7 +111,7 @@ files it was given rather than guarding against other software on the computer.
|
||||
| `batch` | run several commands in order |
|
||||
| `new_document` | blank, sample, letter, resume, report |
|
||||
| `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png |
|
||||
| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) |
|
||||
| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs, in one command and one Undo step: 10,000 lines take ~10 ms; the line breaks are plain breaks, so AutoCorrect doesn't run on them) |
|
||||
| `select_text` | select the n-th occurrence of some text |
|
||||
| `get_text` / `inspect_document` | read the document back (verify without screenshots) |
|
||||
| `render_page` | a page as PNG |
|
||||
|
||||
+63
-2
@@ -1,6 +1,6 @@
|
||||
# Typing parity with Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
What Word does when you type, observed black-box (same keystrokes typed into Word and
|
||||
WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
@@ -8,6 +8,27 @@ WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
tests together. Line breaking and pagination are in [`layout-parity.md`](layout-parity.md); the
|
||||
overall picture is in [`target-app-parity.md`](target-app-parity.md).
|
||||
|
||||
## Highlight shortcut (#4085)
|
||||
|
||||
On Windows/Linux, Ctrl+Alt+H toggles the last chosen highlighter colour (yellow in a
|
||||
new session). If every character in the formatting target already has that colour,
|
||||
it applies explicit No Color; otherwise it applies the chosen colour to the target.
|
||||
Choosing another colour applies it, and choosing No Color makes that the current choice.
|
||||
Explicit `format.highlight {"color": ...}` remains an idempotent setter; omitting `color`
|
||||
uses the toggle. Ribbon and mini-toolbar colour buttons remain setters, not toggles.
|
||||
The highlight decision checks the complete target, including mixed colours after 10,000 runs;
|
||||
formatting-state queries retain their existing sampling limit. Fully selected paragraph marks
|
||||
(including whole cells and empty paragraphs) participate in that decision; partial text
|
||||
columns ignore marks outside the selected range. Palette state is outside document undo; the formatting itself is undoable.
|
||||
The existing selection/word/pending-caret targeting is unchanged (the separate request #4086
|
||||
is not addressed).
|
||||
|
||||
Regressions: `highlight_shortcut_toggles_the_last_colour` and
|
||||
`highlight_toggle_handles_mixed_inherited_and_pending_formatting`. Synthetic command/key
|
||||
qualification, with additional large-selection, non-mutating preview, whole-cell mark,
|
||||
empty-paragraph mark and partial-column controls. Native Word comparison has not been
|
||||
performed for this change.
|
||||
|
||||
## Sample text (#1067)
|
||||
|
||||
Enter at the end of a paragraph containing exactly `=rand()` or `=lorem()` replaces
|
||||
@@ -57,6 +78,11 @@ Bullets and Numbering decide whether to toggle off from the selected paragraphs'
|
||||
|---|---|---|
|
||||
| type, select some of it, type over it | two undo steps: the first ⌘Z brings the selected text back (still selected), the second removes the typing. Uninterrupted typing stays one step | `typing_then_selecting_then_typing_are_two_undo_steps` |
|
||||
| ⌘B with nothing selected, ⌘Z, type | the text isn't bold (Undo takes the waiting caret formatting back too); ⌘Y brings it back | `undoing_bold_at_the_caret_turns_bold_typing_off_again` |
|
||||
| ⌫ at the start of the document, or typing nothing | nothing happens: no undo step is added, the document is not marked edited, and the redo stack survives ³ | `backspace_at_the_start_of_the_document_is_not_an_edit`, `a_command_that_changes_nothing_is_not_an_edit`, `a_no_op_command_keeps_the_redo_stack` |
|
||||
|
||||
³ Not re-observed side by side in Word for this change: Word's undo list does not grow when a
|
||||
key changes nothing, and the document is not marked edited. The gap was found by the read-only
|
||||
audit, which measured WordCraft's own history.
|
||||
|
||||
## Paste
|
||||
|
||||
@@ -113,7 +139,9 @@ The `-->` replacement produces `→` when completed with a space (#2223), includ
|
||||
at paragraph start without creating a list. It respects AutoCorrect preferences, user
|
||||
entries and undo. The existing `-> ` paragraph-start arrow bullet is unchanged.
|
||||
|
||||
Applied when a word is finished by a space, punctuation **or Enter** (`enter_finishes_a_word_for_autocorrect`):
|
||||
Applied when a word is finished by a space, punctuation **or Enter** (`enter_finishes_a_word_for_autocorrect`).
|
||||
The word starts after the last white-space character before it, also when that is a no-break space or an
|
||||
ideographic space (`autocorrect_finds_the_word_after_a_wide_space`):
|
||||
|
||||
- First letter of a sentence (paragraph or list item start, after `. ! ?`) is capitalised; not
|
||||
after abbreviations (`e.g.`, `Dr.`, …) or initials (`J.`), and not for mixed-case words
|
||||
@@ -184,14 +212,43 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin
|
||||
- A field covers only its own result: text typed or pasted with the caret just after a field
|
||||
(a Zotero citation at the end of a paragraph, the caret put there with a click) goes after
|
||||
the field, not into its result (#898; `typing_after_a_citation_goes_outside_its_field`).
|
||||
- With Track Changes on, the border-line and table AutoFormats below mark the characters
|
||||
deleted, so Reject All brings them back; characters the same author inserted are removed.
|
||||
The border is a tracked formatting change, at the top of a story too
|
||||
(`a_tracked_border_line_is_a_deletion_and_a_formatting_change`); the table is inserted
|
||||
untracked, as Insert › Table inserts one
|
||||
(`a_tracked_table_line_keeps_its_characters_as_a_deletion`). A line holding a tracked
|
||||
deletion stays as it is, so Enter after the deleted characters is a plain Enter. This is
|
||||
WordCraft's own rule.
|
||||
- Enter after `---` `===` `***` `___` `~~~` `###` → a bottom border on the paragraph above
|
||||
(single, double, dotted, thick, wave, triple) (`border_line_autoformat`). ⌘Z right after brings the
|
||||
characters back and removes the border; the Enter stays (one undo step) ¹
|
||||
(`undo_right_after_a_border_line_brings_the_characters_back`).
|
||||
- Built-in Heading styles (`tools.autocorrect {"headingStyles": true}`, off by default): Enter
|
||||
twice after a line of five or fewer words that does not end with punctuation makes the line
|
||||
Heading 1; one or two tabs before it make it Heading 2 or 3, and the tabs are removed ¹
|
||||
(`two_enters_after_a_short_line_make_it_a_heading`). The rest is WordCraft's own rule: the
|
||||
line is a Normal paragraph of the main text (not in a list, a table, a header or a text box)
|
||||
and ends in a letter or digit; the caret stays in the empty paragraph after the heading; ⌘Z
|
||||
right after brings back the tabs and the Normal style, and the second Enter stays
|
||||
(`undo_right_after_a_heading_autoformat_keeps_the_enter`); with Track Changes on, the tabs are
|
||||
marked deleted (or removed, when the same author inserted them) and the style change is a
|
||||
tracked formatting change, and a line holding a tracked deletion stays as it is
|
||||
(`a_tracked_heading_autoformat_can_be_rejected`); a heading style locked by formatting
|
||||
restrictions is not applied (`a_locked_heading_style_is_not_applied_as_you_type`).
|
||||
- `+---+------+` Enter (plus signs around runs of hyphens, the whole paragraph) → a one-row
|
||||
table across the text width, each column as wide as its share of the hyphens; the caret goes
|
||||
into the first cell. ⌘Z right after brings the characters back. More than 63 columns stay
|
||||
text ¹ (`plus_and_hyphen_line_becomes_a_table`).
|
||||
- Set left- and first-indent with tabs and backspaces (`tools.autocorrect {"tabIndent": true}`,
|
||||
off by default): Tab before the first line of a paragraph with text indents its first line by
|
||||
one default tab stop, and Tab before any other line, after a line break or where the text
|
||||
wraps, indents the whole paragraph. A second Tab before an indented or hanging first line
|
||||
indents the whole paragraph. Backspace at the start takes the first-line indent back, then the
|
||||
paragraph's own left indent one tab stop at a time. An empty paragraph and Tab inside a line
|
||||
still take a tab character. While formatting is limited to the document's styles, Tab and
|
||||
Backspace work as with the option off ¹ (`tab_and_backspace_at_a_paragraph_start_set_its_indents`,
|
||||
`tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph`).
|
||||
- Smileys `:)` `:-)` `:(` `:-(` `:|` → ☺ ☹ 😐, also straight after a word (`ok:)` → `ok☺`) ¹
|
||||
(`smileys_are_replaced_inside_words_too`).
|
||||
|
||||
@@ -357,6 +414,10 @@ again.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | trivial | AutoFormat As You Type: `*Bold* and _italic_ as real formatting` turns `*word*` bold and `_word_` italic without the marks; off by default |
|
||||
| 2026-10-24 | trivial | AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain 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-22 | trivial | Input methods: while composing, the caret and the candidate window follow the input method's cursor inside the marked text |
|
||||
| 2026-10-21 | 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) |
|
||||
| 2026-10-21 | trivial | List AutoFormat (`- `, `1. `, …) triggers at the start of an already-written paragraph, not only a fresh empty one; checked in Word for Windows (#3352) |
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user