mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 01:01:12 +00:00
Merge origin/main into feat/2738-underline-menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
763f9ca44e
120 files changed
+5575
-385
No files matched your search
+1
-1
@@ -67,7 +67,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
|
||||
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
|
||||
|
||||
Generated
+1
@@ -5223,6 +5223,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"zeroize",
|
||||
|
||||
+29
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Home › Underline drop-down, #2738; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: 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: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; 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
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Home › Underline drop-down, #2738; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: 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: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; 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
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** 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,8 +137,20 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 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-15 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) |
|
||||
| 2026-10-15 | AutoCorrect follows Turkish and Azerbaijani case mapping: `istanbul` → `İstanbul`, `ılık` → `Ilık`, `İNgilizce` → `İngilizce` with no combining dot (#1923) |
|
||||
| 2026-10-17 | Home › Underline's drop-down shows each underline as a sample of its line, adds Dot-dot-dash, None and More Underlines…, and an Underline Color submenu backed by the new `format.underlineColor` command (catalog 490/505) (#2738) |
|
||||
| 2026-10-16 | Releasing a table drag with the mouse (column or row border, move handle, selection areas) leaves the view where it is instead of scrolling back to the caret (#1738) |
|
||||
| 2026-10-16 | Hebrew in fields and quotes (#1670): a STYLEREF (or any field) showing right-to-left text such as "פרק א" reads right to left in headers and the body instead of reversed; smart quotes in Hebrew pinned to Word's logical-order result (“ before, ” after, ” in acronyms like צה"ל) |
|
||||
| 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) |
|
||||
| 2026-10-15 | Image size limits are checked before allocating (#1321): a picture whose header declares more RGBA bytes than the 512 MiB budget is refused before decoding (render and PDF share one helper in `wordcraft-geom`), and page rasters are capped at 100 million pixels in total, scaled down rather than only per side. |
|
||||
| 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). |
|
||||
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
|
||||
|---|---|
|
||||
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
|
||||
| 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. |
|
||||
| 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading |
|
||||
| 2026-10-15 | Reversing an automatic sentence capital (Undo, or Backspace and retype) adds the word before it to AutoCorrect's First Letter exceptions, as Word does; "Automatically add words to list" switches for First Letter and INitial CAps (#1857). |
|
||||
| 2026-10-15 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) |
|
||||
| 2026-10-15 | Font box: a Recently Used Fonts section at the top of the list (the fonts applied lately, most recent first, ten at most, kept between runs), then All Fonts, as in Word (#2068) |
|
||||
| 2026-10-15 | A missing Thai document font (TH Sarabun New, Angsana New, Cordia New, the UPC families…) is substituted by one Thai face for the whole run, Latin letters and digits included, as Word substitutes Cordia New; Thai no longer sits in one font next to larger Latin in another (#1549) |
|
||||
|---|---|
|
||||
@@ -160,6 +172,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
| 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. |
|
||||
| 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips |
|
||||
| 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. |
|
||||
| 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) |
|
||||
| 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. |
|
||||
@@ -170,6 +183,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word |
|
||||
| 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) |
|
||||
| 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 |
|
||||
| 2026-10-15 | Draw: Ink to Shape makes two-stroke arrows and arrows with a closed triangle head. A line plus a separate head stroke (an open V or a small closed triangle, drawn after the line or before it) whose apex sits at one of the line's ends becomes one straight arrow connector, and the head stroke goes, in one undo step; this works while drawing with Ink to Shape on and on lasso-selected strokes with Convert to Shapes. A shaft with a small triangle drawn round its tip in the same stroke is an arrow too; a triangle without a shaft stays a triangle. Our own design (Word for Mac has no Ink to Shape). |
|
||||
| 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box |
|
||||
| 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon |
|
||||
| 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) |
|
||||
@@ -181,6 +195,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | Cut copies to the system clipboard however it is run (#1265): the ribbon, the context menu and the native menu cut deleted the words without copying them (only the Cmd+X event wrote the clipboard, so Paste re-inserted the previous copy); Copy/Cut publication is centralised in one sink and every entry point is tested |
|
||||
| 2026-10-13 | Insert › Bookmark deletes bookmarks (#1236): the dialog is laid out as Word's, with Add, Delete and Go To beside the list, Sort by Name or Location and Hidden bookmarks; it opens on the bookmark around the caret, Delete and Go To keep it open, and Go To selects the bookmark's text. `insert.deleteBookmark {name}` removes both ends of a bookmark, also one across paragraphs (adding a bookmark under a name in use left such a one's end behind) |
|
||||
| 2026-10-12 | Password-protected Word 97-2003 `.doc` files open after a password prompt (XOR obfuscation, RC4, RC4 CryptoAPI; wrong passwords are rejected by the stored verifier, #1079); written from the public specs and tested on synthetic files only, AES-encrypted `.doc` files are still refused |
|
||||
| 2026-10-14 | Update Field works inside headers, footers, notes and text boxes, on the part being edited, as in Word (#1706); with the caret in the body it still leaves them alone, and printing updates every field in the document |
|
||||
| 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents |
|
||||
| 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it |
|
||||
| 2026-10-12 | Page background pictures and textures (Design › Page Color › Fill Effects) are drawn (tiled or stretched) and kept on save instead of being dropped from the package (#999); PDF export paints them too |
|
||||
@@ -436,6 +451,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-18 | trivial | Progress log: Different First Page headers reachable from page 1 (#2826) |
|
||||
| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) |
|
||||
| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) |
|
||||
| 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) |
|
||||
| 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
@@ -448,6 +465,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-16 | trivial | Progress log: legacy outline, shadow, emboss and imprint text effects drawn (#2534) |
|
||||
| 2026-10-15 | trivial | Progress log: Columns on selected text (#2024) |
|
||||
| 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) |
|
||||
| 2026-10-15 | trivial | Progress log: numbered headings (#1793) |
|
||||
| 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged |
|
||||
| 2026-10-15 | trivial | Progress log: Update Field updates the part being edited, as in Word (#1706) |
|
||||
| 2026-10-14 | trivial | Progress log: Update Fields reaches headers, footers and text boxes (#1706) |
|
||||
| 2026-10-15 | trivial | Progress log: font family caches bounded; math font resolution follows font changes (#1354) |
|
||||
| 2026-10-15 | trivial | Progress log: web typing in non-Latin text no longer repeats (#1480) |
|
||||
| 2026-10-15 | trivial | Progress log: Backspace removes a selected table, rows or columns (#1844) |
|
||||
| 2026-10-16 | trivial | Progress log: Hebrew field results and smart quotes (#1670) |
|
||||
| 2026-10-14 | trivial | Progress log: row table border exceptions |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
@@ -461,6 +486,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table |
|
||||
| 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) |
|
||||
| 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged |
|
||||
| 2026-10-15 | trivial | Progress log: Ink to Shape two-stroke and closed-head arrows |
|
||||
| 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) |
|
||||
| 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) |
|
||||
| 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too |
|
||||
@@ -636,3 +662,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
|
||||
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
|
||||
| 2026-10-15 | trivial | Progress log: dialog close box (#1885) |
|
||||
| 2026-10-15 | trivial | Progress log: mixed Hebrew/Latin text keeps its order in Word (#1543) |
|
||||
@@ -82,7 +82,7 @@ mod tests {
|
||||
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"theme\":42}")] {
|
||||
let mut a = app();
|
||||
restore(&mut a, junk);
|
||||
assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::Light);
|
||||
assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::System, "junk keeps the default appearance");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,3 +135,13 @@ sync_github_name = false
|
||||
real_name = "Andrej Škvorc"
|
||||
display_name = "Andrej Škvorc"
|
||||
sync_github_name = false
|
||||
|
||||
[people.olekkut]
|
||||
real_name = "Aleksander Kutycki"
|
||||
display_name = "Aleksander Kutycki"
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
+225
-30
@@ -74,6 +74,19 @@ impl Fragment {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether block `b`, in a block list the paragraph walk reaches at `depth`, has cells the walk
|
||||
/// skips ([`crate::PARA_WALK_DEPTH`]). Bounded by that depth.
|
||||
fn deeper_than_walk(b: &Block, depth: usize) -> bool {
|
||||
match b {
|
||||
Block::Para(_) => false,
|
||||
Block::Table(t) => t
|
||||
.rows
|
||||
.iter()
|
||||
.flat_map(|r| &r.cells)
|
||||
.any(|c| depth + 1 > crate::PARA_WALK_DEPTH || c.blocks.iter().any(|x| deeper_than_walk(x, depth + 1))),
|
||||
}
|
||||
}
|
||||
|
||||
fn order(a: &Pos, b: &Pos) -> (Pos, Pos) {
|
||||
if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) }
|
||||
}
|
||||
@@ -425,7 +438,9 @@ impl Document {
|
||||
return Ok(a);
|
||||
}
|
||||
// Different containers (range enters or leaves a table): clear text in every paragraph
|
||||
// inside; remove whole tables that are entirely covered when at the same level as `a`.
|
||||
// inside, then remove the tables it covers whole (below).
|
||||
// Whether `b` is at its paragraph's end, before that paragraph is trimmed.
|
||||
let b_at_end = self.para(b.story, &b.path).is_some_and(|p| p.len() == b.off);
|
||||
let paths = self.paths_between(&a, &b);
|
||||
for p in paths.iter().rev() {
|
||||
let (from, to) = {
|
||||
@@ -436,41 +451,68 @@ impl Document {
|
||||
};
|
||||
self.para_mut(a.story, p)?.delete_except(from, to, &kept_in(p))?;
|
||||
}
|
||||
// Drop top-level tables strictly between a and b at a's level.
|
||||
if a.path.depth() == 0 && b.path.depth() > 0 {
|
||||
let first_b = b.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let ia = a.path.last();
|
||||
let bl = self.container_mut(a.story, &a.path)?;
|
||||
let mut i = first_b;
|
||||
while i > ia + 1 {
|
||||
i -= 1;
|
||||
// Tables the range covers whole, in the innermost container holding both ends, go (#1198,
|
||||
// #1844): those strictly between the ends, the one `a` is in when it starts at that
|
||||
// table's very start, and the one `b` is in when it ends at that table's very end. A range
|
||||
// that is one table and nothing else only empties it (Word's Delete keeps the table).
|
||||
let common = a.path.0.iter().zip(&b.path.0).take_while(|(x, y)| x == y).count();
|
||||
let level = common - common % 3;
|
||||
let (Some(&ia), Some(&ib)) = (a.path.0.get(level), b.path.0.get(level)) else { return Ok(a) };
|
||||
let (ia, ib) = (ia as usize, ib as usize);
|
||||
if ia >= ib {
|
||||
return Ok(a);
|
||||
}
|
||||
let prefix = a.path.0.get(..level).unwrap_or_default().to_vec();
|
||||
let block_path = |i: usize| {
|
||||
let mut v = prefix.clone();
|
||||
v.push(i as u32);
|
||||
Path(v)
|
||||
};
|
||||
// Paragraphs inside the block at index `i` of the container, in document order.
|
||||
let paths = self.para_paths(a.story);
|
||||
let inside = |i: usize| -> Vec<&Path> {
|
||||
let pre = block_path(i);
|
||||
paths.iter().filter(|q| q.0.len() > pre.0.len() && q.0.starts_with(&pre.0)).collect()
|
||||
};
|
||||
// A table with cells deeper than the paragraph walk goes has paragraphs `inside` never
|
||||
// lists, so where it starts or ends isn't known: it is never taken as covered.
|
||||
let walked = |i: usize| self.container(a.story, &block_path(i)).and_then(|bl| bl.get(i)).is_some_and(|b| !deeper_than_walk(b, level / 3));
|
||||
let a_covers = a.path.0.len() > level + 1 && a.off == 0 && walked(ia) && inside(ia).first() == Some(&&a.path);
|
||||
let b_covers = b.path.0.len() > level + 1 && walked(ib) && inside(ib).last() == Some(&&b.path) && b_at_end;
|
||||
let first = if a_covers { ia } else { ia + 1 };
|
||||
let last = if b_covers { ib } else { ib - 1 };
|
||||
let mut removed = 0;
|
||||
{
|
||||
let bl = self.container_mut(a.story, &block_path(ia))?;
|
||||
for i in (first..=last.min(bl.len().saturating_sub(1))).rev() {
|
||||
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.remove(i);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// From the very start of a top-level table to a top-level paragraph (Select All in a
|
||||
// document that starts with a table, #1198): the tables are covered whole and go too,
|
||||
// and the paragraphs left between join.
|
||||
if a.path.depth() > 0 && b.path.depth() == 0 && a.off == 0 {
|
||||
let ta = a.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let first = self.para_paths(a.story).into_iter().find(|q| q.0.first().is_some_and(|t| *t as usize == ta));
|
||||
if first.as_ref() == Some(&a.path) {
|
||||
let ib = b.path.last();
|
||||
let bl = self.container_mut(a.story, &b.path)?;
|
||||
let mut removed = 0;
|
||||
for i in (ta..ib).rev() {
|
||||
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.remove(i);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
let at = Pos { story: a.story, path: Path::top(ta), off: 0 };
|
||||
let end = Pos { story: a.story, path: Path::top(ib.saturating_sub(removed)), off: 0 };
|
||||
return self.delete_range(&at, &end);
|
||||
if removed == 0 {
|
||||
return Ok(a);
|
||||
}
|
||||
// A story or cell always ends with a paragraph.
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
// The paragraphs left between the ends join, as in a range within one container.
|
||||
let at = if a_covers { Pos { story: a.story, path: block_path(ia), off: 0 } } else { a.clone() };
|
||||
let end = if b_covers {
|
||||
Pos { story: a.story, path: block_path((ib + 1).saturating_sub(removed)), off: 0 }
|
||||
} else {
|
||||
Pos { story: a.story, path: block_path(ib.saturating_sub(removed)), off: 0 }
|
||||
};
|
||||
let paras = |p: &Pos| p.path.0.len() == level + 1 && self.para(p.story, &p.path).is_some();
|
||||
if paras(&at) && paras(&end) && at.path <= end.path {
|
||||
return self.delete_range(&at, &end);
|
||||
}
|
||||
if self.para(at.story, &at.path).is_some() {
|
||||
return Ok(at);
|
||||
}
|
||||
Ok(self.clamp(&at))
|
||||
}
|
||||
|
||||
/// Apply `f` to the character formatting of `a..b`. A collapsed range changes nothing (the
|
||||
@@ -989,4 +1031,157 @@ mod tests {
|
||||
assert_eq!(c, Pos::body(0, 2));
|
||||
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
|
||||
}
|
||||
|
||||
// delete_range over whole tables (#1844), in the body, a header and a footnote.
|
||||
|
||||
/// A document whose story (the body, or a header or footnote part) holds `lines`.
|
||||
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
|
||||
match kind {
|
||||
None => (doc(lines), StoryRef::Body),
|
||||
Some(k) => {
|
||||
let mut d = doc(&["body"]);
|
||||
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
|
||||
let id = d.add_part(k, blocks);
|
||||
(d, StoryRef::Part(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
|
||||
|
||||
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
|
||||
Pos { story, path: Path(path.to_vec()), off }
|
||||
}
|
||||
|
||||
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
|
||||
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
|
||||
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
|
||||
for (k, t) in texts.iter().enumerate() {
|
||||
let mut p = path.to_vec();
|
||||
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
|
||||
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The story's top-level blocks: a paragraph's text, or `[table]`.
|
||||
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
|
||||
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
|
||||
}
|
||||
|
||||
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
|
||||
let mut p = table.to_vec();
|
||||
p.extend([r, c, 0]);
|
||||
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
|
||||
for kind in STORIES {
|
||||
for (r, c) in [(0, 1), (1, 0)] {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
|
||||
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
|
||||
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
|
||||
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
|
||||
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
|
||||
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
|
||||
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["p"]);
|
||||
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
|
||||
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
|
||||
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
|
||||
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
|
||||
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
|
||||
assert_eq!(cell, ["pq"], "{kind:?}");
|
||||
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
|
||||
for kind in STORIES {
|
||||
for trailing in [true, false] {
|
||||
let (mut d, st) = story_doc(kind, &[""]);
|
||||
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
|
||||
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
|
||||
if !trailing {
|
||||
// A story that ends in a table (a hostile file).
|
||||
d.story_mut(st).unwrap().pop();
|
||||
}
|
||||
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
|
||||
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
|
||||
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content controls pair their markers within one block list (a story, or a cell), so a
|
||||
/// marker in a table never has its partner outside it: a control inside a table removed whole
|
||||
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
|
||||
#[test]
|
||||
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
use crate::control::ContentControl;
|
||||
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
|
||||
for kind in STORIES {
|
||||
let (mut d, st) = story_doc(kind, &["before", "after"]);
|
||||
put_table(&mut d, st, &[1], 1, 1, &["c"]);
|
||||
// One control around "efo" in "before", one around the cell's text.
|
||||
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
|
||||
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(st).len(), 2, "setup");
|
||||
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
|
||||
// From inside the first control to the end of the table.
|
||||
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
|
||||
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
|
||||
let ranges = d.control_ranges(st);
|
||||
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
|
||||
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
|
||||
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
|
||||
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
|
||||
// the innermost paragraphs come first in the document but the paragraph walk stops short.
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
|
||||
}
|
||||
for _ in 0..20 {
|
||||
let mut outer = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
t = outer;
|
||||
}
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
|
||||
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
|
||||
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
|
||||
}
|
||||
}
|
||||
@@ -488,6 +488,10 @@ pub struct CoreProps {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub track_changes: bool,
|
||||
/// Record a tracked cut and paste of a sentence or paragraph as a move (Track moves); off
|
||||
/// when the file has `w:doNotTrackMoves` (ECMA-376 §17.15.1.29), and then it is an
|
||||
/// ordinary deletion and insertion.
|
||||
pub track_moves: bool,
|
||||
/// Default tab stop interval, points.
|
||||
pub default_tab: f32,
|
||||
pub even_odd_headers: bool,
|
||||
@@ -654,6 +658,7 @@ impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
track_changes: false,
|
||||
track_moves: true,
|
||||
default_tab: 36.0,
|
||||
even_odd_headers: false,
|
||||
mirror_margins: false,
|
||||
@@ -1443,8 +1448,11 @@ pub fn count_words(s: &str) -> usize {
|
||||
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
if depth > 16 {
|
||||
if depth > PARA_WALK_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
|
||||
|
||||
use crate::math::{
|
||||
Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
|
||||
Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
|
||||
};
|
||||
use crate::math_linear::{FUNCTION_NAMES, map_args};
|
||||
use crate::math_symbols::{name_of, symbol};
|
||||
@@ -380,9 +380,30 @@ impl Lp {
|
||||
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() }))
|
||||
}
|
||||
"operatorname" => {
|
||||
let starred = self.peek() == Some(&Tok::Ch('*'));
|
||||
if starred {
|
||||
self.i += 1;
|
||||
}
|
||||
let t = self.raw_arg();
|
||||
let name = vec![MNode::Run(MRun::plain(t))];
|
||||
self.apply(name)
|
||||
if starred && self.peek() == Some(&Tok::Sub) {
|
||||
self.i += 1;
|
||||
let lim = self.arg();
|
||||
vec![MNode::Lim { upper: false, e: name, lim }]
|
||||
} else {
|
||||
self.apply(name)
|
||||
}
|
||||
}
|
||||
"tag" => {
|
||||
let starred = self.peek() == Some(&Tok::Ch('*'));
|
||||
if starred {
|
||||
self.i += 1;
|
||||
}
|
||||
let t = self.raw_arg();
|
||||
let num_str = if starred { format!("#{t}") } else { format!("# ({t})") };
|
||||
let mut r = MRun::new(num_str);
|
||||
r.lit = false;
|
||||
vec![MNode::Run(r)]
|
||||
}
|
||||
"mathrm" | "rm" => {
|
||||
let a = self.arg();
|
||||
@@ -542,6 +563,22 @@ impl Lp {
|
||||
}
|
||||
|
||||
fn environment(&mut self, env: &str) -> Arg {
|
||||
let base = env.trim_end_matches('*');
|
||||
let mut col_jc = Vec::new();
|
||||
if base == "array" || base == "tabular" {
|
||||
let _ = self.opt();
|
||||
let spec = self.raw_arg();
|
||||
for ch in spec.chars() {
|
||||
match ch {
|
||||
'l' | 'L' => col_jc.push(ColJc::Left),
|
||||
'r' | 'R' => col_jc.push(ColJc::Right),
|
||||
'c' | 'C' => col_jc.push(ColJc::Center),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else if base == "aligned" {
|
||||
let _ = self.opt();
|
||||
}
|
||||
// Rows of `&`-separated cells up to `\end{…}`.
|
||||
let mut rows: Vec<Vec<Arg>> = vec![vec![]];
|
||||
loop {
|
||||
@@ -569,7 +606,6 @@ impl Lp {
|
||||
if rows.len() > 1 && rows.last().is_some_and(|r| r.iter().all(|c| c.is_empty())) {
|
||||
rows.pop();
|
||||
}
|
||||
let base = env.trim_end_matches('*');
|
||||
let delims = match base {
|
||||
"pmatrix" => Some((Some('('), Some(')'))),
|
||||
"bmatrix" => Some((Some('['), Some(']'))),
|
||||
@@ -587,8 +623,8 @@ impl Lp {
|
||||
elems: vec![vec![inner]],
|
||||
};
|
||||
match base {
|
||||
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" => {
|
||||
let m = MNode::Matrix { rows, col_jc: Vec::new() };
|
||||
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => {
|
||||
let m = MNode::Matrix { rows, col_jc };
|
||||
match delims {
|
||||
Some((b, e)) => vec![wrap(m, b, e)],
|
||||
None => vec![m],
|
||||
@@ -890,12 +926,16 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
s.push(' ');
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Delim { beg, end, elems, .. } => {
|
||||
MNode::Delim { beg, end, sep, elems, .. } => {
|
||||
s.push_str("\\left");
|
||||
s.push_str(&delim_tex(*beg));
|
||||
let mid = match sep {
|
||||
Some(c) => format!("\\middle{}", delim_tex(Some(*c))),
|
||||
None => "\\middle|".to_string(),
|
||||
};
|
||||
for (i, e) in elems.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("\\middle|");
|
||||
s.push_str(&mid);
|
||||
}
|
||||
arg(e, s, depth);
|
||||
}
|
||||
@@ -1070,4 +1110,46 @@ mod tests {
|
||||
let deep = "\\hat".repeat(5000) + "x";
|
||||
let _ = parse_latex(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_column_specs_and_aligned_optional_args() {
|
||||
let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}");
|
||||
let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") };
|
||||
assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(crate::math::to_linear(&rows[0][0]), "a");
|
||||
assert_eq!(crate::math::to_linear(&rows[0][1]), "b");
|
||||
assert_eq!(crate::math::to_linear(&rows[0][2]), "c");
|
||||
|
||||
let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}");
|
||||
assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operatorname_starred_and_tag() {
|
||||
let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)");
|
||||
assert!(matches!(&n[0], MNode::Lim { upper: false, .. }));
|
||||
|
||||
let n = parse_latex(r"E=mc^2 \tag{1}");
|
||||
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
|
||||
assert!(crate::math::has_number_mark(&rows[0]));
|
||||
|
||||
let n = parse_latex(r"E=mc^2 \tag*{(A)}");
|
||||
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
|
||||
assert!(crate::math::has_number_mark(&rows[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delim_preserves_custom_separator() {
|
||||
let delim = MNode::Delim {
|
||||
beg: Some('{'),
|
||||
end: Some('}'),
|
||||
sep: Some('‖'),
|
||||
grow: true,
|
||||
shp_match: false,
|
||||
elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]],
|
||||
};
|
||||
let tex = to_latex(&[delim]);
|
||||
assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}");
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ fn u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
||||
if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? != 1 {
|
||||
if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? < 1 {
|
||||
return None;
|
||||
}
|
||||
if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 {
|
||||
@@ -65,12 +65,14 @@ fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
||||
.min(set.len());
|
||||
set.get(start..end)
|
||||
};
|
||||
let cp = property(1)?;
|
||||
if u32_at(cp, 0)? != 2 {
|
||||
return None;
|
||||
} // VT_I2
|
||||
let codepage = u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?);
|
||||
let codepage = property(1)
|
||||
.and_then(|cp| {
|
||||
let vt = u32_at(cp, 0)?;
|
||||
if vt == 2 || vt == 3 { Some(u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?)) } else { None }
|
||||
})
|
||||
.unwrap_or(1252);
|
||||
let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default();
|
||||
let get_filetime = |id| property(id).and_then(filetime_iso).unwrap_or_default();
|
||||
Some(CoreProps {
|
||||
title: get(2),
|
||||
subject: get(3),
|
||||
@@ -79,10 +81,47 @@ fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
||||
description: get(6),
|
||||
last_modified_by: get(8),
|
||||
revision: get(9).parse().unwrap_or_default(),
|
||||
created: get_filetime(12),
|
||||
modified: get_filetime(13),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719_468;
|
||||
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
|
||||
let doe = (z - era * 146_097) as u32;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = y + i64::from(m <= 2);
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn filetime_iso(value: &[u8]) -> Option<String> {
|
||||
let kind = u32_at(value, 0)?;
|
||||
if kind != 64 {
|
||||
return None;
|
||||
}
|
||||
let ft_low = u32_at(value, 4)? as u64;
|
||||
let ft_high = u32_at(value, 8)? as u64;
|
||||
let ft = (ft_high << 32) | ft_low;
|
||||
if ft == 0 {
|
||||
return None;
|
||||
}
|
||||
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
|
||||
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
|
||||
let unix_secs = unix_100ns / 10_000_000;
|
||||
let days = (unix_secs / 86_400) as i64;
|
||||
let secs_of_day = (unix_secs % 86_400) as u32;
|
||||
let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
|
||||
let (y, m, d) = civil(days);
|
||||
Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"))
|
||||
}
|
||||
|
||||
fn text(value: &[u8], codepage: u16) -> Option<String> {
|
||||
let kind = u32_at(value, 0)?;
|
||||
let count = u32_at(value, 4)? as usize;
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(crate) struct RowInfo {
|
||||
/// automatic `Shd` (`None`) clears it.
|
||||
shading: Vec<(u8, Option<Rgb>)>,
|
||||
widths: Vec<(u8, f32)>,
|
||||
width_pcts: Vec<(u8, f32)>,
|
||||
valign: Vec<(u8, VAlign)>,
|
||||
}
|
||||
|
||||
@@ -93,6 +94,18 @@ impl RowInfo {
|
||||
pub(crate) fn shading_of(&self, i: u8) -> Option<Rgb> {
|
||||
self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s)
|
||||
}
|
||||
|
||||
/// The width cell `i` ends up with, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn width_of(&self, i: u8) -> Option<f32> {
|
||||
self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
|
||||
}
|
||||
|
||||
/// The width percentage cell `i` ends up with, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn width_pct_of(&self, i: u8) -> Option<f32> {
|
||||
self.width_pcts.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
@@ -187,13 +200,20 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
}
|
||||
}
|
||||
T_CELL_WIDTH => {
|
||||
// cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth.
|
||||
if let [a, b, fts, w0, w1, ..] = prl.operand
|
||||
&& *fts == 3
|
||||
{
|
||||
let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
r.widths.push((c, w));
|
||||
// Operand: cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes).
|
||||
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635).
|
||||
// ftsWidth: 3 = twips (dxa), 2 = percentage (50ths of a percent).
|
||||
if let [_, a, b, fts, w0, w1, ..] = prl.operand {
|
||||
if *fts == 3 {
|
||||
let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
r.widths.push((c, w));
|
||||
}
|
||||
} else if *fts == 2 {
|
||||
let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
r.width_pcts.push((c, pct));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,13 +276,16 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
if let Some((_, m)) = info.vmerge.iter().find(|(k, _)| *k == i) {
|
||||
c.props.vmerge = *m;
|
||||
}
|
||||
if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) {
|
||||
if let Some((_, w)) = info.widths.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.width = Some(*w);
|
||||
}
|
||||
if let Some((_, pct)) = info.width_pcts.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.width_pct = Some(*pct);
|
||||
}
|
||||
if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.shading = *s;
|
||||
}
|
||||
if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) {
|
||||
if let Some((_, v)) = info.valign.iter().rfind(|(k, _)| *k == i) {
|
||||
c.props.valign = Some(*v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2285,3 +2285,48 @@ fn second_table_gets_its_own_grid() {
|
||||
assert_eq!(tables[1].props.align, None, "alignment reset");
|
||||
assert_eq!(tables[1].props.indent, None, "indent reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprm_t_cell_width_twips_and_percentage() {
|
||||
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635):
|
||||
// cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes).
|
||||
// ftsWidth = 3 (twips): 1440 twips = 72.0 pt.
|
||||
let w_twips = [0u8, 2, 3, 0xA0, 0x05]; // cell 0..2, fts=3, 1440 dxa
|
||||
// ftsWidth = 2 (percentage in 50ths of a percent): 2500 = 50.0%.
|
||||
let w_pct = [2u8, 3, 2, 0xC4, 0x09]; // cell 2..3, fts=2, 2500 (50%)
|
||||
let papx = [sprm_cb(0xD635, &w_twips), sprm_cb(0xD635, &w_pct)].concat();
|
||||
|
||||
let row = crate::table::decode(&papx);
|
||||
assert_eq!(row.width_of(0), Some(72.0));
|
||||
assert_eq!(row.width_of(1), Some(72.0));
|
||||
assert_eq!(row.width_pct_of(2), Some(50.0));
|
||||
|
||||
// End-to-end document test: row cells receive the preferred widths.
|
||||
let text = "a\u{7}b\u{7}c\u{7}\u{7}\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let ttp = [
|
||||
grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]),
|
||||
t_def_edges(&[0, 1440, 2880, 4320]),
|
||||
sprm_cb(0xD635, &w_twips),
|
||||
sprm_cb(0xD635, &w_pct),
|
||||
]
|
||||
.concat();
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, in_tbl), (6, 0, ttp), (7, 0, Vec::new())],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let table = doc.body[0].as_table().expect("table block");
|
||||
assert_eq!(table.rows[0].cells[0].props.width, Some(72.0));
|
||||
assert_eq!(table.rows[0].cells[1].props.width, Some(72.0));
|
||||
assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0));
|
||||
}
|
||||
@@ -96,3 +96,69 @@ fn malformed_or_oversized_summary_does_not_break_doc_open() {
|
||||
assert!(open_with_metadata(&huge, false).core.title.is_empty());
|
||||
assert!(open_with_metadata(&vec![0; (2 << 20) + 1], false).core.title.is_empty());
|
||||
}
|
||||
|
||||
fn custom_summary(props: &[(u32, Vec<u8>)], num_property_sets: u32) -> Vec<u8> {
|
||||
let mut section = vec![0; 8 + 8 * props.len()];
|
||||
section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes());
|
||||
for (i, (id, value)) in props.iter().enumerate() {
|
||||
section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes());
|
||||
let offset = section.len() as u32;
|
||||
section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes());
|
||||
section.extend_from_slice(value);
|
||||
}
|
||||
let size = section.len() as u32;
|
||||
section[..4].copy_from_slice(&size.to_le_bytes());
|
||||
let mut header = vec![0; 28 + 20 * num_property_sets as usize];
|
||||
header[..2].copy_from_slice(&[0xfe, 0xff]);
|
||||
header[24..28].copy_from_slice(&num_property_sets.to_le_bytes());
|
||||
// First property set FMTID and offset
|
||||
header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]);
|
||||
let first_offset = header.len() as u32;
|
||||
header[44..48].copy_from_slice(&first_offset.to_le_bytes());
|
||||
if num_property_sets > 1 {
|
||||
// Dummy second property set header
|
||||
header[48..64].copy_from_slice(&[1; 16]);
|
||||
let second_offset = (first_offset + size) as u32;
|
||||
header[64..68].copy_from_slice(&second_offset.to_le_bytes());
|
||||
}
|
||||
header.extend(section);
|
||||
header
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_defaults_codepage_and_accepts_vti4_and_num_property_sets() {
|
||||
let title_prop = [30u32.to_le_bytes().as_slice(), &7u32.to_le_bytes(), b"Default\0"].concat();
|
||||
// 1. Missing PID_CODEPAGE (property 1) should default to 1252 per MS-OLEPS.
|
||||
let no_cp = custom_summary(&[(2, title_prop.clone())], 1);
|
||||
assert_eq!(open_with_metadata(&no_cp, false).core.title, "Default");
|
||||
|
||||
// 2. PID_CODEPAGE as VT_I4 (type 3).
|
||||
let cp_vti4 = [3u32.to_le_bytes().as_slice(), &1252u32.to_le_bytes()].concat();
|
||||
let with_vti4 = custom_summary(&[(1, cp_vti4), (2, title_prop.clone())], 1);
|
||||
assert_eq!(open_with_metadata(&with_vti4, false).core.title, "Default");
|
||||
|
||||
// 3. Stream with NumPropertySets = 2.
|
||||
let multi_sets = custom_summary(&[(2, title_prop)], 2);
|
||||
assert_eq!(open_with_metadata(&multi_sets, false).core.title, "Default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_parses_filetime_created_and_modified_dates() {
|
||||
// 2024-03-15T12:30:45Z = 133549794450000000 in 100ns since 1601-01-01
|
||||
let ft_val: u64 = 133_549_794_450_000_000;
|
||||
let ft_low = (ft_val & 0xFFFFFFFF) as u32;
|
||||
let ft_high = (ft_val >> 32) as u32;
|
||||
let filetime_bytes = [64u32.to_le_bytes().as_slice(), &ft_low.to_le_bytes(), &ft_high.to_le_bytes()].concat();
|
||||
|
||||
let stream = custom_summary(
|
||||
&[
|
||||
(12, filetime_bytes.clone()), // PIDSI_CREATE_DTM
|
||||
(13, filetime_bytes), // PIDSI_LASTSAVE_DTM
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
let core = open_with_metadata(&stream, false).core;
|
||||
assert_eq!(core.created, "2024-03-15T12:30:45Z");
|
||||
assert_eq!(core.modified, "2024-03-15T12:30:45Z");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
|
||||
@@ -79,7 +79,8 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
|
||||
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
|
||||
/// unknown (`None`).
|
||||
pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
|
||||
Some(match name {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
Some(match lower.as_str() {
|
||||
"black" => Rgb(0, 0, 0),
|
||||
"white" => Rgb(0xFF, 0xFF, 0xFF),
|
||||
"red" => Rgb(0xFF, 0, 0),
|
||||
@@ -91,8 +92,8 @@ pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
|
||||
"magenta" | "fuchsia" => Rgb(0xFF, 0, 0xFF),
|
||||
"gray" | "grey" => Rgb(0x80, 0x80, 0x80),
|
||||
"silver" => Rgb(0xC0, 0xC0, 0xC0),
|
||||
"ltGray" | "ltGrey" => Rgb(0xD3, 0xD3, 0xD3),
|
||||
"dkGray" | "dkGrey" => Rgb(0xA9, 0xA9, 0xA9),
|
||||
"ltgray" | "ltgrey" | "lightgray" | "lightgrey" => Rgb(0xD3, 0xD3, 0xD3),
|
||||
"dkgray" | "dkgrey" | "darkgray" | "darkgrey" => Rgb(0xA9, 0xA9, 0xA9),
|
||||
"maroon" => Rgb(0x80, 0, 0),
|
||||
"navy" => Rgb(0, 0, 0x80),
|
||||
"olive" => Rgb(0x80, 0x80, 0),
|
||||
@@ -101,6 +102,18 @@ pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
|
||||
"orange" => Rgb(0xFF, 0xA5, 0),
|
||||
"brown" => Rgb(0xA5, 0x2A, 0x2A),
|
||||
"pink" => Rgb(0xFF, 0xC0, 0xCB),
|
||||
"gold" => Rgb(0xFF, 0xD7, 0),
|
||||
"darkblue" | "dkblue" => Rgb(0, 0, 0x8B),
|
||||
"darkred" | "dkred" => Rgb(0x8B, 0, 0),
|
||||
"darkgreen" | "dkgreen" => Rgb(0, 0x64, 0),
|
||||
"lightblue" | "ltblue" => Rgb(0xAD, 0xD8, 0xE6),
|
||||
"coral" => Rgb(0xFF, 0x7F, 0x50),
|
||||
"salmon" => Rgb(0xFA, 0x80, 0x72),
|
||||
"tomato" => Rgb(0xFF, 0x63, 0x47),
|
||||
"violet" => Rgb(0xEE, 0x82, 0xEE),
|
||||
"indigo" => Rgb(0x4B, 0, 0x82),
|
||||
"beige" => Rgb(0xF5, 0xF5, 0xDC),
|
||||
"ivory" => Rgb(0xFF, 0xFF, 0xF0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -241,4 +254,16 @@ mod tests {
|
||||
let b = el("a:srgbClr", r#"val="808080""#, r#"<a:lumOff val="50000"/><a:lumMod val="50000"/>"#);
|
||||
assert_ne!(color(&a, &theme), color(&b, &theme));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_colors_are_case_insensitive_and_include_common_names() {
|
||||
assert_eq!(preset_color("red"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(preset_color("Red"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(preset_color("darkBlue"), Some(Rgb(0, 0, 0x8B)));
|
||||
assert_eq!(preset_color("dkBlue"), Some(Rgb(0, 0, 0x8B)));
|
||||
assert_eq!(preset_color("lightGray"), Some(Rgb(0xD3, 0xD3, 0xD3)));
|
||||
assert_eq!(preset_color("ltGray"), Some(Rgb(0xD3, 0xD3, 0xD3)));
|
||||
assert_eq!(preset_color("gold"), Some(Rgb(0xFF, 0xD7, 0)));
|
||||
assert_eq!(preset_color("unknownColor"), None);
|
||||
}
|
||||
}
|
||||
@@ -1088,6 +1088,7 @@ impl Reader<'_> {
|
||||
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
|
||||
}
|
||||
"w:trackRevisions" => s.track_changes = on_off(k),
|
||||
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
|
||||
"w:defaultTabStop" => {
|
||||
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
|
||||
s.default_tab = v.clamp(1.0, 1584.0);
|
||||
|
||||
@@ -169,18 +169,39 @@ fn dash_ratio(value: &str) -> Option<u32> {
|
||||
Some(units.round() as u32)
|
||||
}
|
||||
|
||||
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb` or `#rgb` (Word follows it with the
|
||||
/// theme slot, as in `#4472c4 [3204]`), or a colour name.
|
||||
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb`, `rrggbb`, `#rgb` or `rgb` (Word follows it
|
||||
/// with the theme slot, as in `#4472c4 [3204]` or `4472c4 [3204]`), an `rgb(...)` function, or a
|
||||
/// colour name.
|
||||
pub(crate) fn vml_color(v: &str) -> Option<Rgb> {
|
||||
let v = v.split_whitespace().next()?;
|
||||
if let Some(hex) = v.strip_prefix('#') {
|
||||
if hex.len() == 3 && hex.is_ascii() {
|
||||
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
|
||||
return Rgb::parse(&long);
|
||||
let s = v.trim();
|
||||
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
|
||||
let nums: Vec<u8> = inner
|
||||
.trim_end_matches(')')
|
||||
.split(',')
|
||||
.take(3)
|
||||
.filter_map(|x| {
|
||||
let x = x.trim();
|
||||
if let Some(p) = x.strip_suffix('%') {
|
||||
p.trim().parse::<f32>().ok().map(|f| (f.clamp(0.0, 100.0) * 2.55).round() as u8)
|
||||
} else {
|
||||
x.parse::<f32>().ok().map(|f| f.clamp(0.0, 255.0).round() as u8)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let [r, g, b] = nums[..] {
|
||||
return Some(Rgb(r, g, b));
|
||||
}
|
||||
}
|
||||
let tok = s.split_whitespace().next()?;
|
||||
let hex = tok.strip_prefix('#').unwrap_or(tok);
|
||||
if hex.len() == 3 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
|
||||
return Rgb::parse(&long);
|
||||
}
|
||||
if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Rgb::parse(hex);
|
||||
}
|
||||
super::drawing_color::preset_color(v)
|
||||
super::drawing_color::preset_color(tok)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -279,8 +300,13 @@ mod tests {
|
||||
#[test]
|
||||
fn vml_colours() {
|
||||
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
|
||||
assert_eq!(vml_color("ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
|
||||
assert_eq!(vml_color("#f00"), Some(Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(vml_color("f00"), Some(Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(vml_color("black"), Some(Rgb(0, 0, 0)));
|
||||
assert_eq!(vml_color("Red"), Some(Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(vml_color("rgb(255, 128, 0)"), Some(Rgb(255, 128, 0)));
|
||||
assert_eq!(vml_color("rgb(100%, 0%, 0%)"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(vml_color("#é"), None);
|
||||
assert_eq!(vml_color(""), None);
|
||||
}
|
||||
|
||||
+138
-14
@@ -30,6 +30,8 @@ const MAX_TABLE_DEPTH: usize = 24;
|
||||
const MAX_FIELD_DEPTH: usize = 32;
|
||||
/// Deepest text-box-in-text-box nesting.
|
||||
const MAX_STORY_DEPTH: usize = 4;
|
||||
/// Deepest `v:group` nesting followed (hostile files).
|
||||
const MAX_VML_GROUP_DEPTH: usize = 8;
|
||||
/// Longest field code we keep (Zotero's citation codes embed item data as JSON).
|
||||
const MAX_INSTR: usize = 2 * 1024 * 1024;
|
||||
/// Longest legacy form field definition (`w:ffData`) we keep.
|
||||
@@ -295,17 +297,25 @@ impl Reader<'_> {
|
||||
}
|
||||
|
||||
fn marker(&mut self, e: &El) -> Option<InlineObject> {
|
||||
let id = e.attr("w:id").unwrap_or("").to_string();
|
||||
let id = e.attr("w:id").map(str::trim).unwrap_or("").to_string();
|
||||
match e.name.as_str() {
|
||||
"w:bookmarkStart" => {
|
||||
let name = e.attr("w:name").unwrap_or("").to_string();
|
||||
if self.bookmarks.len() >= 100_000 {
|
||||
return None;
|
||||
}
|
||||
let name = e.attr("w:name").map(str::trim).unwrap_or("").to_string();
|
||||
if name.is_empty() || name.len() > 256 {
|
||||
return None;
|
||||
}
|
||||
self.bookmarks.insert(id, name.clone());
|
||||
Some(InlineObject::BookmarkStart { name })
|
||||
}
|
||||
"w:bookmarkEnd" => self.bookmarks.get(&id).map(|n| InlineObject::BookmarkEnd { name: n.clone() }),
|
||||
"w:bookmarkEnd" => self
|
||||
.bookmarks
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.or_else(|| e.attr("w:name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 256).map(str::to_string))
|
||||
.map(|name| InlineObject::BookmarkEnd { name }),
|
||||
"w:commentRangeStart" => self.comment_id(&id).map(|id| InlineObject::CommentStart { id }),
|
||||
"w:permStart" => {
|
||||
// Bounded: a hostile file can't grow the map without end.
|
||||
@@ -662,7 +672,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"w:pict" => {
|
||||
if let Some(o) = self.read_vml(sc, k, rels) {
|
||||
for o in self.read_vml_all(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
@@ -1399,20 +1409,125 @@ impl Reader<'_> {
|
||||
self.doc.add_part(PartKind::TextBox, blocks)
|
||||
}
|
||||
|
||||
/// Legacy VML pictures, text boxes, lines, rectangles, ovals and preset shapes (best effort).
|
||||
/// Every legacy VML drawing in one `w:pict`, in document order: a `w:pict` may hold any
|
||||
/// number of shapes (ECMA-376 Part 4, VML), and each one is its own object. A `v:group`
|
||||
/// becomes an [`InlineObject::Group`].
|
||||
fn read_vml_all(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Vec<InlineObject> {
|
||||
let mut out = Vec::new();
|
||||
for e in pict.els().filter(|e| e.name.starts_with("v:") && e.local() != "shapetype") {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
break;
|
||||
}
|
||||
if sc.in_hf
|
||||
&& let Some(wm) = vml_watermark(e)
|
||||
{
|
||||
// The document's watermark (the first one found); drawn on every page, not as
|
||||
// a shape, so it is not one of the objects this picture contributes.
|
||||
self.doc.settings.watermark.get_or_insert(wm);
|
||||
continue;
|
||||
}
|
||||
let o = if e.local() == "group" { self.read_vml_group(sc, pict, e, rels) } else { self.read_vml_shape(sc, pict, e, rels, None) };
|
||||
out.extend(o);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first VML drawing in `pict`, for the places that show a single one: an OLE object's
|
||||
/// picture and a `mc:Fallback` preview.
|
||||
fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let shape = pict.els().find(|e| e.name.starts_with("v:") && e.local() != "shapetype")?;
|
||||
if sc.in_hf
|
||||
&& let Some(wm) = vml_watermark(shape)
|
||||
{
|
||||
// The document's watermark (the first one found); drawn on every page, not as a shape.
|
||||
self.doc.settings.watermark.get_or_insert(wm);
|
||||
self.read_vml_all(sc, pict, rels).into_iter().next()
|
||||
}
|
||||
|
||||
/// A `v:group`: its own box from its `style`, its members in the space `coordsize` and
|
||||
/// `coordorigin` give it, nested groups flattened into it as [`Self::group_members`] does for
|
||||
/// DrawingML. `None` when it has no member we can show. `pict` is the picture the group sits
|
||||
/// in, where a member's `v:shapetype` is written.
|
||||
fn read_vml_group(&mut self, sc: &mut StoryCtx, pict: &El, grp: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let style = grp.attr("style").unwrap_or("");
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
|
||||
let (w, h) = (len("width"), len("height"));
|
||||
let float = vml_float(grp, style);
|
||||
// The members' space; without a usable `coordsize` it is the group's own box.
|
||||
let (ch_w, ch_h) = vml_coordpair(grp, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((w.max(1.0), h.max(1.0)));
|
||||
let (cx, cy) = vml_coordpair(grp, "coordorigin").unwrap_or((0.0, 0.0));
|
||||
let mut children = Vec::new();
|
||||
self.vml_group_members(sc, pict, grp, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None })
|
||||
}
|
||||
|
||||
/// The members of VML group `g` into `out`, mapped into the outermost group's space by `t`
|
||||
/// (x0, y0, sx, sy: outer = x0 + inner × sx). A member is placed by `left`/`top` in that
|
||||
/// space, except a `v:line`, which carries its own offset in `from`.
|
||||
fn vml_group_members(
|
||||
&mut self,
|
||||
sc: &mut StoryCtx,
|
||||
pict: &El,
|
||||
g: &El,
|
||||
rels: &Rels,
|
||||
t: (f32, f32, f32, f32),
|
||||
depth: usize,
|
||||
out: &mut Vec<wordcraft_doc::para::GroupChild>,
|
||||
) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
|
||||
for e in g.els() {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return;
|
||||
}
|
||||
if !e.name.starts_with("v:") || e.local() == "shapetype" {
|
||||
continue;
|
||||
}
|
||||
let style = e.attr("style").unwrap_or("");
|
||||
let num = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).map_or(0.0, wordcraft_geom::finite);
|
||||
let ((ox, oy), (ew, eh)) = ((num("left"), num("top")), (num("width"), num("height")));
|
||||
if e.local() == "group" {
|
||||
if depth >= MAX_VML_GROUP_DEPTH {
|
||||
continue;
|
||||
}
|
||||
// Its members' space maps onto its own box.
|
||||
let (cw, ch) = vml_coordpair(e, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
|
||||
let (cox, coy) = vml_coordpair(e, "coordorigin").unwrap_or((0.0, 0.0));
|
||||
let (kx, ky) = (ew / cw, eh / ch);
|
||||
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
|
||||
self.vml_group_members(sc, pict, e, rels, nt, depth + 1, out);
|
||||
continue;
|
||||
}
|
||||
let Some(mut obj) = self.read_vml_shape(sc, pict, e, rels, Some((sx, sy))) else { continue };
|
||||
// A member is placed by `x`/`y`, so a line's own offset comes off the member itself.
|
||||
let (dx, dy) = match obj.float_mut() {
|
||||
Some(f) => {
|
||||
let d = (f.x, f.y);
|
||||
(f.x, f.y) = (0.0, 0.0);
|
||||
d
|
||||
}
|
||||
None => (0.0, 0.0),
|
||||
};
|
||||
out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + (ox + dx) * sx, -max), y: fit(y0 + (oy + dy) * sy, -max), obj });
|
||||
}
|
||||
}
|
||||
|
||||
/// One legacy VML picture, text box, line, rectangle, oval or preset shape (best effort).
|
||||
/// `pict` is the picture it sits in, where its `v:shapetype` is written -- for a group's
|
||||
/// member too, since Word writes each shapetype once, beside the group rather than inside
|
||||
/// it. `grp` scales a member from its group's space, and leaves placing it to the caller.
|
||||
fn read_vml_shape(&mut self, sc: &mut StoryCtx, pict: &El, shape: &El, rels: &Rels, grp: Option<(f32, f32)>) -> Option<InlineObject> {
|
||||
let style = shape.attr("style").unwrap_or("");
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
|
||||
let (mut w, mut h) = (len("width"), len("height"));
|
||||
let mut float = vml_float(shape, style);
|
||||
let (sx, sy) = grp.unwrap_or((1.0, 1.0));
|
||||
let (mut w, mut h) = ((len("width") * sx).min(crate::units::MAX_LEN_PT), (len("height") * sy).min(crate::units::MAX_LEN_PT));
|
||||
// A member of a group never floats: the group does, and places it.
|
||||
let mut float = match grp {
|
||||
Some(_) => {
|
||||
let mut f = Float { hidden: vml_style(style, "visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden")), ..Float::default() };
|
||||
f.set_spin(vml_spin(style));
|
||||
f
|
||||
}
|
||||
None => vml_float(shape, style),
|
||||
};
|
||||
if let Some(img) = shape.find("v:imagedata") {
|
||||
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
|
||||
let media = self.media_for(rels, id)?;
|
||||
@@ -1532,7 +1647,7 @@ impl Reader<'_> {
|
||||
};
|
||||
let ((x0, y0), (x1, y1)) = (pt("from"), pt("to"));
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
(w, h) = ((x1 - x0).abs().min(max), (y1 - y0).abs().min(max));
|
||||
(w, h) = (((x1 - x0).abs() * sx).min(max), ((y1 - y0).abs() * sy).min(max));
|
||||
float.x = (float.x + x0.min(x1)).clamp(-max, max);
|
||||
float.y = (float.y + y0.min(y1)).clamp(-max, max);
|
||||
// Drawn top-left to bottom-right; flipped for the other directions.
|
||||
@@ -1792,6 +1907,15 @@ fn vml_spt(shape: &El, shapetype: Option<&El>, type_id: Option<&str>) -> Option<
|
||||
/// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins).
|
||||
/// The first `v:textbox` under `e` holding text, with its `w:txbxContent` (depth-first, bounded
|
||||
/// like `El::find`): in a group, a member's margins go with that member's text.
|
||||
/// A VML `coordsize="w,h"` or `coordorigin="x,y"` pair (ECMA-376 Part 4 §14.1.2): the member
|
||||
/// space of a group, in its own unitless coordinates.
|
||||
fn vml_coordpair(e: &El, name: &str) -> Option<(f32, f32)> {
|
||||
let v = e.attr(name)?;
|
||||
let (x, y) = v.split_once(',')?;
|
||||
let n = |s: &str| measure(s.trim(), 1.0).map(wordcraft_geom::finite);
|
||||
Some((n(x)?, n(y)?))
|
||||
}
|
||||
|
||||
fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return None;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Run direction for mixed right-to-left and left-to-right text (#1543).
|
||||
//!
|
||||
//! WordCraft lays a paragraph out with the Unicode Bidirectional Algorithm (UAX #9) from its
|
||||
//! text and direction alone, so a run need not say its direction. Word orders a run's characters
|
||||
//! by the run's `w:rtl` (ECMA-376 Part 1 §17.3.2.30): text in a run without it is laid out left
|
||||
//! to right, so Hebrew words on either side of a Latin name, and the punctuation between them,
|
||||
//! change places. A run that doesn't say its direction is therefore split where the resolved
|
||||
//! level changes parity, and the right-to-left pieces are written `w:rtl`, as Word writes text
|
||||
//! typed in a right-to-left paragraph. Runs that say their direction, and paragraphs without
|
||||
//! right-to-left characters, are written as they are.
|
||||
//!
|
||||
//! Word gives right-to-left text without a bidi language (`w:lang w:bidi`, §17.3.2.20) its
|
||||
//! default complex-script language, Arabic, so Hebrew would proof and show as Arabic. The pieces
|
||||
//! marked `w:rtl` therefore also say their language, unless the run or its styles already do.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::styles::StyleSheet;
|
||||
|
||||
/// The language Word expects for right-to-left text of `c`'s script, for the scripts that have
|
||||
/// one usual language.
|
||||
fn script_lang(c: char) -> Option<&'static str> {
|
||||
match u32::from(c) {
|
||||
0x0590..=0x05FF | 0xFB1D..=0xFB4F => Some("he-IL"),
|
||||
0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF => Some("ar-SA"),
|
||||
0x0700..=0x074F => Some("syr-SY"),
|
||||
0x0780..=0x07BF => Some("dv-MV"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The language of the first right-to-left letter of `text` with a usual language.
|
||||
fn text_lang(text: &str) -> Option<&'static str> {
|
||||
text.chars().find_map(script_lang)
|
||||
}
|
||||
|
||||
/// A language tag of a right-to-left language (`he-IL`, `yi`, `fa-IR`, `ar-EG`…)?
|
||||
fn is_rtl_lang(tag: &str) -> bool {
|
||||
let primary = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
|
||||
matches!(primary.as_str(), "he" | "iw" | "yi" | "ar" | "fa" | "ur" | "ps" | "sd" | "ug" | "ckb" | "syr" | "dv" | "ku")
|
||||
}
|
||||
|
||||
/// Does a paragraph of style `para_style` inherit a bidi language from the document defaults or
|
||||
/// its style chain? (Resolved once per paragraph.)
|
||||
pub(super) fn para_inherits_lang_bidi(styles: &StyleSheet, para_style: Option<&str>) -> bool {
|
||||
styles.para_style_props(para_style.unwrap_or("Normal")).1.lang_bidi.is_some()
|
||||
}
|
||||
|
||||
/// Does `c`'s character style chain give it a bidi language?
|
||||
pub(super) fn char_style_has_lang_bidi(styles: &StyleSheet, c: &CharProps) -> bool {
|
||||
c.style.as_deref().is_some_and(|s| styles.chain(s).iter().any(|st| st.chr.lang_bidi.is_some()))
|
||||
}
|
||||
|
||||
/// Add `rtl` and, when given, the bidi language `lang` to `c` where it doesn't say them.
|
||||
fn direct(c: &mut CharProps, lang: Option<&String>) {
|
||||
c.rtl.get_or_insert(true);
|
||||
if let Some(l) = lang
|
||||
&& c.lang_bidi.is_none()
|
||||
{
|
||||
c.lang_bidi = Some(l.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// `runs` (byte ranges of `text` with their props), split where the bidi level of `text` in a
|
||||
/// paragraph of direction `rtl_para` changes parity; right-to-left pieces get `rtl: Some(true)`
|
||||
/// and, unless they have one or `inherits_lang` says it comes from a style, a bidi language.
|
||||
pub(super) fn direction_spans<'a>(
|
||||
text: &str,
|
||||
rtl_para: bool,
|
||||
runs: impl Iterator<Item = (Range<usize>, &'a CharProps)>,
|
||||
inherits_lang: impl Fn(&CharProps) -> bool,
|
||||
) -> Vec<(Range<usize>, Cow<'a, CharProps>)> {
|
||||
if !wordcraft_doc::bidi::has_rtl(text) {
|
||||
return runs.map(|(r, p)| (r, Cow::Borrowed(p))).collect();
|
||||
}
|
||||
let level = if rtl_para { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() };
|
||||
let info = unicode_bidi::ParagraphBidiInfo::new(text, Some(level));
|
||||
let odd = |at: usize| info.levels.get(at).is_some_and(|l| l.is_rtl());
|
||||
let para_lang = text_lang(text);
|
||||
let mut out = Vec::new();
|
||||
for (range, props) in runs {
|
||||
let Some(run) = text.get(range.clone()).filter(|_| props.rtl.is_none()) else {
|
||||
out.push((range, Cow::Borrowed(props)));
|
||||
continue;
|
||||
};
|
||||
let named = props.lang_bidi.is_some() || inherits_lang(props);
|
||||
let piece = |r: Range<usize>, rtl: bool| {
|
||||
if !rtl {
|
||||
return (r, Cow::Borrowed(props));
|
||||
}
|
||||
let lang = if named {
|
||||
None
|
||||
} else {
|
||||
let own = props.lang.clone().filter(|l| is_rtl_lang(l));
|
||||
own.or_else(|| text.get(r.clone()).and_then(text_lang).or(para_lang).map(str::to_string))
|
||||
};
|
||||
let mut p = props.clone();
|
||||
direct(&mut p, lang.as_ref());
|
||||
// A tracked formatting change's "before" copy gets the same, so the change lists
|
||||
// nothing the author didn't do.
|
||||
if let Some(ch) = p.fmt_change.as_mut() {
|
||||
direct(&mut ch.old, lang.as_ref());
|
||||
}
|
||||
(r, Cow::Owned(p))
|
||||
};
|
||||
let mut start = range.start;
|
||||
let mut cur: Option<bool> = None;
|
||||
for (i, _) in run.char_indices() {
|
||||
let at = range.start.saturating_add(i);
|
||||
let rtl = odd(at);
|
||||
match cur {
|
||||
Some(c) if c != rtl => {
|
||||
out.push(piece(start..at, c));
|
||||
start = at;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cur = Some(rtl);
|
||||
}
|
||||
out.push(piece(start..range.end, cur.unwrap_or(false)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_ranges_never_panic() {
|
||||
let p = CharProps::default();
|
||||
let text = "עברית WordCraft";
|
||||
let runs = vec![(0..3, &p), (3..1000, &p), (usize::MAX - 1..usize::MAX, &p), (2..text.len(), &p)];
|
||||
let out = direction_spans(text, true, runs.into_iter(), |_| false);
|
||||
assert_eq!(out.len(), 5, "bad ranges pass through unsplit: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languages_by_script() {
|
||||
assert_eq!(text_lang("(שלום"), Some("he-IL"));
|
||||
assert_eq!(text_lang("سلام"), Some("ar-SA"));
|
||||
assert_eq!(text_lang("ܫܠܡܐ"), Some("syr-SY"));
|
||||
assert_eq!(text_lang(". ,"), None);
|
||||
assert!(is_rtl_lang("he-IL") && is_rtl_lang("yi") && is_rtl_lang("FA_ir") && !is_rtl_lang("en-US") && !is_rtl_lang(""));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! [`Document`] → DOCX.
|
||||
|
||||
mod bidi;
|
||||
mod embed;
|
||||
mod glossary;
|
||||
mod settings_merge;
|
||||
@@ -1005,6 +1006,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
|
||||
if s.track_changes {
|
||||
w.empty("w:trackRevisions", &[]);
|
||||
}
|
||||
if !s.track_moves {
|
||||
w.empty("w:doNotTrackMoves", &[]);
|
||||
}
|
||||
if let Some(p) = &s.protection {
|
||||
let mut a: Vec<(&str, &str)> = Vec::new();
|
||||
if p != "none" {
|
||||
|
||||
@@ -21,6 +21,7 @@ const OWNED: &[&str] = &[
|
||||
"w:attachedTemplate",
|
||||
"w:linkStyles",
|
||||
"w:trackRevisions",
|
||||
"w:doNotTrackMoves",
|
||||
"w:documentProtection",
|
||||
"w:defaultTabStop",
|
||||
"w:autoHyphenation",
|
||||
|
||||
@@ -247,7 +247,16 @@ impl Writer<'_> {
|
||||
// Split into pieces with their props.
|
||||
let mut pieces: Vec<(Piece, &CharProps)> = Vec::new();
|
||||
let mut k = 0usize;
|
||||
for (range, props) in p.run_ranges() {
|
||||
// Runs of mixed direction are split so Word orders them as WordCraft does (#1543).
|
||||
let rtl_para = self.doc.styles.resolve_para(&p.props).bidi;
|
||||
let styles = &self.doc.styles;
|
||||
let para_style = p.props.style.as_deref();
|
||||
let para_lang = super::bidi::para_inherits_lang_bidi(styles, para_style);
|
||||
let spans =
|
||||
super::bidi::direction_spans(&p.text, rtl_para, p.run_ranges(), |c| para_lang || super::bidi::char_style_has_lang_bidi(styles, c));
|
||||
for (range, props) in &spans {
|
||||
let props: &CharProps = props;
|
||||
let range = range.clone();
|
||||
let base = range.start;
|
||||
let Some(text) = p.text.get(range) else { continue };
|
||||
let mut buf = String::new();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Runs of mixed right-to-left and left-to-right text (#1543). WordCraft lays a paragraph out with
|
||||
//! the Unicode Bidirectional Algorithm (UAX #9) from its text and direction alone, while Word
|
||||
//! orders a run's characters by the run's `w:rtl` (ECMA-376 Part 1 §17.3.2.30): text in a run
|
||||
//! without it is laid out left to right, so Hebrew words around a Latin name, and the punctuation
|
||||
//! between them, change places. The writer therefore splits such a run where the resolved
|
||||
//! direction changes and marks the right-to-left pieces `w:rtl`, as Word does with text typed
|
||||
//! in a right-to-left paragraph.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
|
||||
/// `word/document.xml` of a written package.
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The runs of the first paragraph of `xml`: (has `w:rtl`, text with `w:br` as `\n`).
|
||||
fn runs(xml: &str) -> Vec<(bool, String)> {
|
||||
let body = xml.split("<w:body>").nth(1).unwrap();
|
||||
let para = body.split("</w:p>").next().unwrap();
|
||||
para.split("<w:r>")
|
||||
.skip(1)
|
||||
.map(|r| {
|
||||
let r = r.split("</w:r>").next().unwrap();
|
||||
let rtl = r.contains("<w:rtl/>");
|
||||
let mut text = String::new();
|
||||
for part in r.split('<').skip(1) {
|
||||
if part.starts_with("w:br/>") {
|
||||
text.push('\n');
|
||||
} else if part.starts_with("w:t ") || part.starts_with("w:t>") {
|
||||
text.push_str(part.split_once('>').unwrap().1);
|
||||
}
|
||||
}
|
||||
(rtl, text)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn arial() -> CharProps {
|
||||
CharProps { font: Some("Arial".into()), font_cs: Some("Arial".into()), size: Some(16.0), size_cs: Some(16.0), ..Default::default() }
|
||||
}
|
||||
|
||||
fn doc_of(text: &str, rtl_para: bool, props: CharProps) -> Document {
|
||||
let mut p = Paragraph::with_text(text, props);
|
||||
if rtl_para {
|
||||
p.props.bidi = Some(true);
|
||||
}
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p)];
|
||||
d
|
||||
}
|
||||
|
||||
fn written_runs(d: &Document) -> Vec<(bool, String)> {
|
||||
runs(&document_xml(&wordcraft_docx::write(d).unwrap()))
|
||||
}
|
||||
|
||||
fn owned(v: &[(bool, &str)]) -> Vec<(bool, String)> {
|
||||
v.iter().map(|(r, t)| (*r, (*t).to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_splits_latin_out_of_rtl_runs() {
|
||||
let d = doc_of("עברית WordCraft 2026 ואחריה עוד עברית.", true, arial());
|
||||
assert_eq!(written_runs(&d), owned(&[(true, "עברית "), (false, "WordCraft 2026"), (true, " ואחריה עוד עברית.")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_paragraph_brackets_email_and_final_stop_take_the_paragraph_direction() {
|
||||
let d = doc_of("לפני (ABC 123) אחרי; דוא״ל test@example.com.", true, arial());
|
||||
assert_eq!(written_runs(&d), owned(&[(true, "לפני ("), (false, "ABC 123"), (true, ") אחרי; דוא״ל "), (false, "test@example.com"), (true, ".")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_stay_in_the_split_runs() {
|
||||
let d = doc_of("שלום עולם.\nעברית WordCraft 2026 ואחריה.", true, arial());
|
||||
assert_eq!(written_runs(&d), owned(&[(true, "שלום עולם.\nעברית "), (false, "WordCraft 2026"), (true, " ואחריה.")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_runs_keep_their_formatting() {
|
||||
let d = doc_of("עברית WordCraft עברית", true, arial());
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
let body = xml.split("<w:body>").nth(1).unwrap();
|
||||
assert_eq!(body.matches("<w:r>").count(), 3);
|
||||
assert_eq!(body.matches(r#"<w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:cs="Arial"/>"#).count(), 4, "three runs and the mark: {body}");
|
||||
assert_eq!(body.matches(r#"<w:szCs w:val="32"/>"#).count(), 4, "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_inside_an_ltr_paragraph_is_marked_right_to_left() {
|
||||
let d = doc_of("English with עברית מעורבת inside.", false, CharProps::default());
|
||||
assert_eq!(written_runs(&d), owned(&[(false, "English with "), (true, "עברית מעורבת"), (false, " inside.")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_without_rtl_characters_is_one_run() {
|
||||
let d = doc_of("Plain English, 2026.", true, arial());
|
||||
assert_eq!(written_runs(&d), owned(&[(false, "Plain English, 2026.")]));
|
||||
let d = doc_of("Plain English, 2026.", false, arial());
|
||||
assert_eq!(written_runs(&d), owned(&[(false, "Plain English, 2026.")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_run_direction_is_kept() {
|
||||
// A run that already says its direction is written as it is, unsplit.
|
||||
let d = doc_of("עברית WordCraft", true, CharProps { rtl: Some(true), ..arial() });
|
||||
assert_eq!(written_runs(&d), owned(&[(true, "עברית WordCraft")]));
|
||||
let d = doc_of("עברית WordCraft", true, CharProps { rtl: Some(false), ..arial() });
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(xml.contains(r#"<w:rtl w:val="0"/>"#), "{xml}");
|
||||
assert_eq!(runs(&xml).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_paragraph_round_trips_text_and_direction() {
|
||||
let text = "לפני (ABC 123) אחרי; דוא״ל test@example.com.\nעברית WordCraft 2026 ואחריה עוד עברית.";
|
||||
let d = doc_of(text, true, arial());
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
let p = r.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p.text, text);
|
||||
assert_eq!(p.props.bidi, Some(true));
|
||||
for run in &p.runs {
|
||||
assert_eq!(run.props.font.as_deref(), Some("Arial"));
|
||||
assert_eq!(run.props.size, Some(16.0));
|
||||
}
|
||||
assert!(p.runs.iter().any(|r| r.props.rtl == Some(true)));
|
||||
// Saving again writes the same runs: the read-back direction is now explicit.
|
||||
assert_eq!(written_runs(&r), written_runs(&d));
|
||||
}
|
||||
|
||||
// Word gives right-to-left text without a `w:lang w:bidi` its default complex-script language
|
||||
// (Arabic), so its status bar, proofing and hyphenation call Hebrew "Arabic". Right-to-left
|
||||
// pieces the writer marks say their language (§17.3.2.20), unless the run or a style already does.
|
||||
|
||||
/// `w:bidi` attribute of each run's `w:lang` in the first paragraph: (has `w:rtl`, bidi language).
|
||||
fn run_langs(xml: &str) -> Vec<(bool, Option<String>)> {
|
||||
let body = xml.split("<w:body>").nth(1).unwrap();
|
||||
let para = body.split("</w:p>").next().unwrap();
|
||||
para.split("<w:r>")
|
||||
.skip(1)
|
||||
.map(|r| {
|
||||
let r = r.split("</w:r>").next().unwrap();
|
||||
let lang = r.split("<w:lang ").nth(1).and_then(|l| l.split("w:bidi=\"").nth(1)).map(|v| v.split('"').next().unwrap().to_string());
|
||||
(r.contains("<w:rtl/>"), lang)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn langs(d: &Document) -> Vec<(bool, Option<String>)> {
|
||||
run_langs(&document_xml(&wordcraft_docx::write(d).unwrap()))
|
||||
}
|
||||
|
||||
fn he(rtl: bool) -> (bool, Option<String>) {
|
||||
(rtl, rtl.then(|| "he-IL".to_string()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_pieces_say_they_are_hebrew() {
|
||||
let d = doc_of("לפני (ABC 123) אחרי; דוא״ל test@example.com.", true, arial());
|
||||
// The final stop has no letters of its own; it takes the paragraph's language.
|
||||
assert_eq!(langs(&d), vec![he(true), he(false), he(true), he(false), he(true)]);
|
||||
let d = doc_of("English with עברית מעורבת inside.", false, CharProps::default());
|
||||
assert_eq!(langs(&d), vec![he(false), he(true), he(false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_pieces_say_they_are_arabic() {
|
||||
let d = doc_of("مرحبا WordCraft بالعالم", true, arial());
|
||||
let ar = Some("ar-SA".to_string());
|
||||
assert_eq!(langs(&d), vec![(true, ar.clone()), (false, None), (true, ar)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_language_the_run_already_has_is_kept() {
|
||||
let d = doc_of("سلام WordCraft دنیا", true, CharProps { lang_bidi: Some("fa-IR".into()), ..arial() });
|
||||
let fa = Some("fa-IR".to_string());
|
||||
assert_eq!(langs(&d), vec![(true, fa.clone()), (false, fa.clone()), (true, fa)]);
|
||||
// A right-to-left proofing language on the run (w:val) names the right-to-left text too.
|
||||
let d = doc_of("שלום WordCraft", true, CharProps { lang: Some("yi".into()), ..arial() });
|
||||
assert_eq!(langs(&d), vec![(true, Some("yi".into())), (false, None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inherited_bidi_language_is_not_repeated() {
|
||||
let mut d = doc_of("سلام WordCraft دنیا", true, arial());
|
||||
d.styles.default_chr.lang_bidi = Some("fa-IR".into());
|
||||
assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
//! Synthetic packages, written here; no external documents or installed fonts are needed.
|
||||
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::{Document, Paragraph};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, Run};
|
||||
|
||||
fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Vec<u8> {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
@@ -29,13 +30,41 @@ fn paragraph(d: &Document) -> &Paragraph {
|
||||
d.body.iter().find_map(|b| b.as_para()).unwrap()
|
||||
}
|
||||
|
||||
/// `runs` without run direction and bidi language (in the formatting change too), adjacent equal
|
||||
/// runs merged.
|
||||
fn undirected(runs: &[Run]) -> Vec<Run> {
|
||||
let strip = |c: &mut CharProps| {
|
||||
c.rtl = None;
|
||||
c.lang_bidi = None;
|
||||
};
|
||||
let mut out: Vec<Run> = Vec::new();
|
||||
for r in runs {
|
||||
let mut props = r.props.clone();
|
||||
strip(&mut props);
|
||||
if let Some(ch) = props.fmt_change.as_mut() {
|
||||
strip(&mut ch.old);
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(last) if last.props == props => last.len += r.len,
|
||||
_ => out.push(Run { len: r.len, props }),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check_generations(bytes: &[u8], check: impl Fn(&Document)) {
|
||||
let mut d = wordcraft_docx::read(bytes).unwrap();
|
||||
for _ in 0..3 {
|
||||
for generation in 0..3 {
|
||||
check(&d);
|
||||
let next = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(paragraph(&next).text, paragraph(&d).text);
|
||||
assert_eq!(paragraph(&next).runs, paragraph(&d).runs);
|
||||
if generation == 0 {
|
||||
// The first save splits the mixed Hebrew/Latin run by direction and names the
|
||||
// Hebrew's language (#1543); apart from that nothing changes.
|
||||
assert_eq!(undirected(¶graph(&next).runs), undirected(¶graph(&d).runs));
|
||||
} else {
|
||||
assert_eq!(paragraph(&next).runs, paragraph(&d).runs);
|
||||
}
|
||||
assert_eq!(paragraph(&next).props.bidi, Some(true));
|
||||
d = next;
|
||||
}
|
||||
@@ -133,3 +162,39 @@ fn tracked_formatting_keeps_independent_complex_script_values() {
|
||||
assert_eq!(overlaid.fmt_change, c.fmt_change);
|
||||
});
|
||||
}
|
||||
|
||||
/// `(w:rtl, w:lang)` elements of a run's current `w:rPr` and of its `w:rPrChange` "before" copy.
|
||||
fn direction_markup(run: &str) -> (String, String) {
|
||||
let pick = |rpr: &str| -> String {
|
||||
rpr.split('<').filter(|t| t.starts_with("w:rtl") || t.starts_with("w:lang")).map(|t| format!("<{t}")).collect::<Vec<_>>().join("")
|
||||
};
|
||||
let (now, change) = run.split_once("<w:rPrChange").unwrap_or((run, ""));
|
||||
(pick(now), pick(change))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_runs_add_no_phantom_tracked_formatting() {
|
||||
// #1543: direction and language the writer adds to a piece of a run that has a tracked
|
||||
// formatting change go into the change's "before" copy too, so Word lists no change the
|
||||
// author never made ("Formatted: Right-to-left, Hebrew").
|
||||
let run = r#"<w:sz w:val="24"/><w:rPrChange w:id="7" w:author="Synthetic Author" w:date="2026-10-11T00:00:00Z"><w:rPr><w:sz w:val="20"/></w:rPr></w:rPrChange>"#;
|
||||
let d = wordcraft_docx::read(&package(run, "", "", "")).unwrap();
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("word/document.xml").unwrap(), &mut xml)
|
||||
.unwrap();
|
||||
let runs: Vec<&str> = xml.split("<w:r>").skip(1).map(|r| r.split("</w:r>").next().unwrap()).collect();
|
||||
assert!(runs.len() >= 2, "the mixed run is split: {xml}");
|
||||
assert!(runs.iter().any(|r| r.contains("<w:rtl/>")), "{xml}");
|
||||
for r in runs {
|
||||
assert!(r.contains("<w:rPrChange"), "every piece keeps the tracked change: {r}");
|
||||
let (now, before) = direction_markup(r);
|
||||
assert_eq!(now, before, "no direction or language difference to report: {r}");
|
||||
}
|
||||
// And WordCraft reads no difference back either.
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
for run in ¶graph(&r).runs {
|
||||
let old = &run.props.fmt_change.as_ref().unwrap().old;
|
||||
assert_eq!((old.rtl, old.lang_bidi.as_deref()), (run.props.rtl, run.props.lang_bidi.as_deref()));
|
||||
}
|
||||
}
|
||||
@@ -2974,3 +2974,25 @@ fn shape_dashes_import_and_save() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_end_name_and_id_trim() {
|
||||
let body = r#"<w:p>
|
||||
<w:bookmarkStart w:id=" 42 " w:name="section_intro"/>
|
||||
<w:r><w:t>Hello</w:t></w:r>
|
||||
<w:bookmarkEnd w:id="42"/>
|
||||
<w:bookmarkEnd w:id="999" w:name="standalone_target"/>
|
||||
</w:p>"#;
|
||||
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = doc.body.first().unwrap().as_para().unwrap();
|
||||
let names: Vec<(&'static str, String)> = p
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::BookmarkStart { name } => Some(("start", name.clone())),
|
||||
InlineObject::BookmarkEnd { name } => Some(("end", name.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, vec![("start", "section_intro".into()), ("end", "section_intro".into()), ("end", "standalone_target".into())]);
|
||||
}
|
||||
@@ -133,3 +133,33 @@ fn settings_written_by_wordcraft_keep_no_original() {
|
||||
assert!(!back.passthrough.contains_key("wordcraft:settings.orig"), "{:?}", back.passthrough.keys());
|
||||
assert_eq!(text(&save(&back), "word/settings.xml"), text(&save(&doc), "word/settings.xml"));
|
||||
}
|
||||
|
||||
/// Gap #5: `w:doNotTrackMoves` (ECMA-376 §17.15.1.29) turns Track moves off; it is read, written
|
||||
/// back right after `w:trackRevisions` (in a merged part and a generated one), and left out
|
||||
/// while moves are tracked.
|
||||
#[test]
|
||||
fn do_not_track_moves_round_trips() {
|
||||
let original = SETTINGS.replace("<w:trackRevisions/>", "<w:trackRevisions/><w:doNotTrackMoves/>");
|
||||
let mut doc = wordcraft_docx::read(&with_settings(&original)).unwrap();
|
||||
assert!(!doc.settings.track_moves);
|
||||
let s = text(&save(&doc), "word/settings.xml");
|
||||
assert_eq!(s.matches("<w:doNotTrackMoves/>").count(), 1, "{s}");
|
||||
assert!(pos(&s, "<w:trackRevisions/>") < pos(&s, "<w:doNotTrackMoves/>") && pos(&s, "<w:doNotTrackMoves/>") < pos(&s, "<w:defaultTabStop"));
|
||||
assert!(s.contains("ClauseSet"), "a merged part: {s}");
|
||||
doc.settings.track_moves = true;
|
||||
let s = text(&save(&doc), "word/settings.xml");
|
||||
assert!(!s.contains("doNotTrackMoves"), "{s}");
|
||||
|
||||
// `w:val="false"` keeps moves tracked.
|
||||
let off = SETTINGS.replace("<w:trackRevisions/>", r#"<w:trackRevisions/><w:doNotTrackMoves w:val="false"/>"#);
|
||||
assert!(wordcraft_docx::read(&with_settings(&off)).unwrap().settings.track_moves);
|
||||
|
||||
// A document of our own: absent by default, written when off, and read back.
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
assert!(doc.settings.track_moves);
|
||||
assert!(!text(&save(&doc), "word/settings.xml").contains("doNotTrackMoves"));
|
||||
doc.settings.track_moves = false;
|
||||
let s = text(&save(&doc), "word/settings.xml");
|
||||
assert!(s.contains("<w:doNotTrackMoves/>"), "{s}");
|
||||
assert!(!wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap().settings.track_moves);
|
||||
}
|
||||
@@ -24,10 +24,11 @@ fn document_xml(bytes: &[u8]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// The text boxes' stories in body order.
|
||||
/// The text boxes' stories in body order, a group's members among them
|
||||
/// (`text_boxes`, not `text_box`, which only sees an object that is itself a box).
|
||||
fn boxes(d: &Document) -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
d.objects_in_reading_order(&mut |o| out.extend(o.text_box()));
|
||||
d.objects_in_reading_order(&mut |o| out.extend(o.text_boxes()));
|
||||
out.retain(|id| d.parts.get(id).is_some_and(|p| p.kind == PartKind::TextBox));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Legacy VML drawings in a `w:pict`: every shape in one, and `v:group` members in the space
|
||||
//! `coordsize`/`coordorigin` give them. Fixtures are hand-written from ECMA-376 Part 4
|
||||
//! (VML, §14.1 `v:group`, `coordsize`, `coordorigin`; §14.2 shapes).
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
use wordcraft_doc::{Document, InlineObject, Paragraph};
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A document whose body is `body`.
|
||||
fn read_body(body: &str) -> Document {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w10="urn:schemas-microsoft-com:office:word"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
wordcraft_docx::read(&zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])).unwrap()
|
||||
}
|
||||
|
||||
fn paras(d: &Document) -> Vec<&Paragraph> {
|
||||
d.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
/// The objects of the body's first paragraph.
|
||||
fn objects(d: &Document) -> &[InlineObject] {
|
||||
¶s(d).first().expect("a paragraph").objects
|
||||
}
|
||||
|
||||
fn pict(inner: &str) -> String {
|
||||
format!(r#"<w:p><w:r><w:pict>{inner}</w:pict></w:r></w:p>"#)
|
||||
}
|
||||
|
||||
fn kinds(objs: &[InlineObject]) -> Vec<ShapeKind> {
|
||||
objs.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { kind, .. } => *kind,
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shape_in_a_pict_is_read() {
|
||||
// A `w:pict` may hold any number of shapes; each one is its own object. A `v:shapetype` is
|
||||
// a definition, not a shape, and is not one of them.
|
||||
let d = read_body(&pict(concat!(
|
||||
r#"<v:shapetype id="t1" o:spt="202"/>"#,
|
||||
r#"<v:rect style="width:10pt;height:20pt"/>"#,
|
||||
r#"<v:oval style="width:30pt;height:40pt"/>"#,
|
||||
r#"<v:roundrect style="width:50pt;height:60pt"/>"#,
|
||||
)));
|
||||
let objs = objects(&d);
|
||||
assert_eq!(kinds(objs), [ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::RoundedRectangle], "all three, in document order");
|
||||
let size = |o: &InlineObject| match o {
|
||||
InlineObject::Shape { w, h, .. } => (*w, *h),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
assert_eq!(objs.iter().map(size).collect::<Vec<_>>(), [(10.0, 20.0), (30.0, 40.0), (50.0, 60.0)], "each keeps its own size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vml_group_keeps_its_members() {
|
||||
// `coordsize` is the members' space, mapped onto the group's own box; `left`/`top` place a
|
||||
// member in that space.
|
||||
let d = read_body(&pict(concat!(
|
||||
r#"<v:group style="width:100pt;height:50pt" coordsize="1000,500">"#,
|
||||
r#"<v:rect style="left:0;top:0;width:400;height:500"/>"#,
|
||||
r#"<v:oval style="left:600;top:100;width:400;height:300"/>"#,
|
||||
r#"</v:group>"#,
|
||||
)));
|
||||
let InlineObject::Group { w, h, ch_w, ch_h, children, canvas, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!((*w, *h, *ch_w, *ch_h), (100.0, 50.0, 1000.0, 500.0), "its own box, and its members' space");
|
||||
assert_eq!(*canvas, None, "a group is not a Drawing Canvas");
|
||||
assert_eq!(children.len(), 2, "both members");
|
||||
assert_eq!((children[0].x, children[0].y), (0.0, 0.0));
|
||||
assert_eq!((children[1].x, children[1].y), (600.0, 100.0), "placed in the members' space, not scaled");
|
||||
assert_eq!(kinds(&children.iter().map(|c| c.obj.clone()).collect::<Vec<_>>()), [ShapeKind::Rectangle, ShapeKind::Ellipse]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vml_group_shifts_its_members_by_its_coordorigin() {
|
||||
// `coordorigin` is the top-left of the members' space, so a member's offset is from there.
|
||||
let d = read_body(&pict(concat!(
|
||||
r#"<v:group style="width:100pt;height:100pt" coordsize="1000,1000" coordorigin="200,300">"#,
|
||||
r#"<v:rect style="left:200;top:300;width:100;height:100"/>"#,
|
||||
r#"<v:rect style="left:700;top:800;width:100;height:100"/>"#,
|
||||
r#"</v:group>"#,
|
||||
)));
|
||||
let InlineObject::Group { ch_w, children, .. } = &objects(&d)[0] else { panic!() };
|
||||
assert_eq!(*ch_w, 1000.0);
|
||||
assert_eq!((children[0].x, children[0].y), (0.0, 0.0), "a member at the origin sits at the group's corner");
|
||||
assert_eq!((children[1].x, children[1].y), (500.0, 500.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_vml_groups_are_flattened_into_the_outer_one() {
|
||||
// An inner group's members' space maps onto the inner group's own box, which is itself in
|
||||
// the outer group's space: 500 wide in a 1000-wide space inside a 1000-wide box halves it.
|
||||
let d = read_body(&pict(concat!(
|
||||
r#"<v:group style="width:100pt;height:100pt" coordsize="1000,1000">"#,
|
||||
r#"<v:rect style="left:100;top:100;width:50;height:50"/>"#,
|
||||
r#"<v:group style="left:400;top:400;width:500;height:500" coordsize="1000,1000">"#,
|
||||
r#"<v:oval style="left:200;top:600;width:100;height:100"/>"#,
|
||||
r#"</v:group>"#,
|
||||
r#"</v:group>"#,
|
||||
)));
|
||||
let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!() };
|
||||
assert_eq!(children.len(), 2, "the inner group's member comes into the outer group");
|
||||
assert_eq!((children[0].x, children[0].y), (100.0, 100.0));
|
||||
// 400 + 200 × (500/1000) = 500; 400 + 600 × 0.5 = 700.
|
||||
assert_eq!((children[1].x, children[1].y), (500.0, 700.0), "mapped through the inner group");
|
||||
let InlineObject::Shape { w, h, .. } = children[1].obj else { panic!() };
|
||||
assert_eq!((w, h), (50.0, 50.0), "and scaled by it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vml_group_with_no_member_we_can_show_is_left_out() {
|
||||
// Only a shape definition, and a shape we draw nothing for (no stroke, no fill).
|
||||
let d = read_body(&pict(concat!(
|
||||
r#"<v:group style="width:100pt;height:50pt" coordsize="1000,500">"#,
|
||||
r#"<v:shapetype id="t1" o:spt="202"/>"#,
|
||||
r#"<v:rect style="left:0;top:0;width:10;height:10" stroked="f" filled="f"/>"#,
|
||||
r#"</v:group>"#,
|
||||
)));
|
||||
assert!(objects(&d).is_empty(), "nothing to show: {:?}", objects(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_vml_groups_stop_at_the_depth_bound() {
|
||||
// A hostile file nests groups without end; reading one must not run away.
|
||||
let depth = 64;
|
||||
let mut inner = r#"<v:rect style="left:0;top:0;width:10;height:10"/>"#.to_string();
|
||||
for _ in 0..depth {
|
||||
inner = format!(r#"<v:group style="left:0;top:0;width:100;height:100" coordsize="100,100">{inner}</v:group>"#);
|
||||
}
|
||||
let d = read_body(&pict(&format!(r#"<v:group style="width:100pt;height:100pt" coordsize="100,100">{inner}</v:group>"#)));
|
||||
// Past the bound nothing is read, so the outermost group has no member and is left out.
|
||||
assert!(objects(&d).is_empty(), "{:?}", objects(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vml_group_survives_being_saved() {
|
||||
let body = pict(concat!(
|
||||
r#"<v:group style="width:100pt;height:50pt" coordsize="1000,500">"#,
|
||||
r#"<v:rect style="left:0;top:0;width:400;height:500"/>"#,
|
||||
r#"<v:oval style="left:600;top:100;width:400;height:300"/>"#,
|
||||
r#"</v:group>"#,
|
||||
));
|
||||
let d = read_body(&body);
|
||||
let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!(children.len(), 2, "read with both its members to begin with");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(objects(&back), objects(&d), "saved as DrawingML and read again, it is the same group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pict_of_a_rect_a_text_box_and_a_group_all_come_through() {
|
||||
// The three shapes of issue #1643, which a round trip dropped the last of. The group's
|
||||
// member gives its size in points, not in the group's own coordinates, as Word writes it.
|
||||
let d = read_body(&format!(
|
||||
"{}{}{}",
|
||||
pict(r##"<v:rect style="position:absolute;width:100pt;height:50pt" fillcolor="#ff0000"/>"##),
|
||||
pict(
|
||||
r#"<v:shape style="width:100pt;height:50pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>boxed</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape>"#
|
||||
),
|
||||
pict(concat!(
|
||||
r#"<v:group style="width:100pt;height:50pt" coordsize="100,50">"#,
|
||||
r##"<v:rect style="width:50pt;height:20pt" fillcolor="#00ff00"/>"##,
|
||||
r#"</v:group>"#,
|
||||
)),
|
||||
));
|
||||
let ps = paras(&d);
|
||||
assert_eq!(ps.len(), 3, "a paragraph each");
|
||||
assert_eq!(kinds(&ps[0].objects), [ShapeKind::Rectangle]);
|
||||
assert_eq!(kinds(&ps[1].objects), [ShapeKind::TextBox]);
|
||||
let InlineObject::Group { children, .. } = &ps[2].objects[0] else { panic!("{:?}", ps[2].objects) };
|
||||
assert_eq!(kinds(&children.iter().map(|c| c.obj.clone()).collect::<Vec<_>>()), [ShapeKind::Rectangle], "the green rectangle is read");
|
||||
let InlineObject::Shape { fill, .. } = children[0].obj else { panic!() };
|
||||
assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00)), "with its colour");
|
||||
// And it is still there after a save, which is what the round trip lost.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(paras(&back).iter().map(|p| p.objects.len()).collect::<Vec<_>>(), [1, 1, 1]);
|
||||
let InlineObject::Group { children, .. } = ¶s(&back)[2].objects[0] else { panic!() };
|
||||
assert_eq!(children.len(), 1, "the group keeps its member");
|
||||
}
|
||||
|
||||
/// The text of text box story `id`, its paragraphs joined by newlines.
|
||||
fn part_text(d: &Document, id: u32) -> String {
|
||||
d.parts.get(&id).map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n")).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_of_several_text_boxes_keeps_every_one() {
|
||||
// Reported on #1643 by @KNUFFIXX, whose fixture is a `_x0000_t202` pair side by side in one
|
||||
// inline `v:group`: only box A came through a convert. `vml_textbox` recurses to the first
|
||||
// box with content, so before this every later box in a group was dropped -- and a text box
|
||||
// is the one member a group used to keep, which is why this case looked half-working.
|
||||
let shape = |left: u32, height: u32, label: &str| {
|
||||
let paras: String = (1..=3).map(|n| format!(r#"<w:p><w:r><w:t xml:space="preserve">{label} line {n}</w:t></w:r></w:p>"#)).collect();
|
||||
format!(
|
||||
r##"<v:shape style="position:absolute;left:{left};top:100;width:3500;height:{height}" type="#_x0000_t202" filled="false" stroked="true"><v:textbox inset="0,0,0,0"><w:txbxContent>{paras}</w:txbxContent></v:textbox></v:shape>"##
|
||||
)
|
||||
};
|
||||
// The control fixture, and the overflow one where box B is too short for its text; the
|
||||
// height of a box must not decide whether it is read.
|
||||
for (height_b, label_b) in [(1600, "Box B (tall)"), (400, "Box B (short)")] {
|
||||
let d = read_body(&pict(&format!(
|
||||
concat!(
|
||||
r##"<v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe"/>"##,
|
||||
r##"<v:group style="width:400pt;height:100pt" coordorigin="0,0" coordsize="8000,2000">{}{}</v:group>"##,
|
||||
),
|
||||
shape(100, 1600, "Box A (tall)"),
|
||||
shape(4200, height_b, label_b),
|
||||
)));
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!((*ch_w, *ch_h), (8000.0, 2000.0), "the group's member space");
|
||||
assert_eq!(children.len(), 2, "both boxes, with box B {height_b} high");
|
||||
assert_eq!((children[0].x, children[1].x), (100.0, 4200.0), "side by side");
|
||||
|
||||
let stories: Vec<u32> = children.iter().filter_map(|c| c.obj.text_box()).collect();
|
||||
assert_eq!(stories.len(), 2, "each box has its own story");
|
||||
assert_ne!(stories[0], stories[1]);
|
||||
assert_eq!(part_text(&d, stories[0]), "Box A (tall) line 1\nBox A (tall) line 2\nBox A (tall) line 3");
|
||||
assert_eq!(part_text(&d, stories[1]), format!("{label_b} line 1\n{label_b} line 2\n{label_b} line 3"));
|
||||
|
||||
// Saved and read again -- the step the report loses the text at.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let InlineObject::Group { children: again, .. } = &objects(&back)[0] else { panic!() };
|
||||
let back_stories: Vec<u32> = again.iter().filter_map(|c| c.obj.text_box()).collect();
|
||||
assert_eq!(back_stories.len(), 2, "both boxes survive the save");
|
||||
assert_eq!(part_text(&back, back_stories[0]), part_text(&d, stories[0]));
|
||||
assert_eq!(part_text(&back, back_stories[1]), part_text(&d, stories[1]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_member_finds_its_shapetype_beside_the_group() {
|
||||
// Word writes each `v:shapetype` once, at its first use, as a child of the `w:pict` -- so a
|
||||
// group's member refers to one that is its *parent's* sibling, not its own. The member here
|
||||
// carries no geometry and no colours of its own: its diamond shape comes from the
|
||||
// shapetype's `o:spt` (4) and its fill from the shapetype's `fillcolor`.
|
||||
let d = read_body(&pict(concat!(
|
||||
r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#0000ff"/>"##,
|
||||
r#"<v:group style="width:100pt;height:50pt" coordsize="1000,500">"#,
|
||||
r##"<v:shape style="left:0;top:0;width:400;height:500" type="#_x0000_t4"/>"##,
|
||||
r#"</v:group>"#,
|
||||
)));
|
||||
let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!(children.len(), 1, "the member is read");
|
||||
let InlineObject::Shape { kind, fill, .. } = children[0].obj else { panic!("{:?}", children[0].obj) };
|
||||
assert_eq!(kind, ShapeKind::Diamond, "the preset comes from the shapetype's o:spt");
|
||||
assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0x00, 0xff)), "and the fill from its fillcolor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_level_shape_still_finds_its_shapetype() {
|
||||
// The same lookup for a shape that is not in a group, which is what it was written for.
|
||||
let d = read_body(&pict(concat!(
|
||||
r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#00ff00"/>"##,
|
||||
r##"<v:shape style="width:40pt;height:40pt" type="#_x0000_t4"/>"##,
|
||||
)));
|
||||
let InlineObject::Shape { kind, fill, .. } = objects(&d)[0] else { panic!("{:?}", objects(&d)) };
|
||||
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00))));
|
||||
}
|
||||
@@ -83,6 +83,7 @@ Home|Paragraph|Bullets|para.bullets
|
||||
Home|Paragraph|Numbering|para.numbering
|
||||
Home|Paragraph|Multilevel List|para.multilevel
|
||||
Home|Paragraph|Define New Multilevel List|list.define
|
||||
Home|Paragraph|Multilevel List Library|list.multilevelPreset
|
||||
Home|Paragraph|Change List Level|para.listLevel
|
||||
Home|Paragraph|Restart Numbering|para.restartNumbering
|
||||
Home|Paragraph|Define New Bullet|para.defineBullet
|
||||
|
||||
@@ -159,6 +159,13 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
}
|
||||
return super::sel_result(s);
|
||||
}
|
||||
// Backspace over whole rows, whole columns or the whole table deletes them, as in Word
|
||||
// (Delete only clears them, as for any other cells).
|
||||
if id == "text.backspace"
|
||||
&& let Some(r) = super::table::backspace_cells(s)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
|
||||
let cells = s.cell_range().is_some();
|
||||
match id {
|
||||
|
||||
+300
-13
@@ -22,7 +22,7 @@ use wordcraft_doc::{CharProps, Pos, StoryRef};
|
||||
use wordcraft_geom::Rect;
|
||||
use wordcraft_layout::{DocLayout, Placed};
|
||||
|
||||
use super::ink_shape::{InkShape, recognise};
|
||||
use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise};
|
||||
use super::pos_json;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
@@ -461,9 +461,26 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
|
||||
extra: Default::default(),
|
||||
};
|
||||
let at = place(s, &layout, obj, page, (fx, fy), (first[0], first[1]))?;
|
||||
// Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step).
|
||||
let shape = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { convert_at(s, &at)? } else { None };
|
||||
Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh], "shape": shape}))
|
||||
// Ink to Shape: a stroke that looks like a shape becomes it, and a line and a head drawn as two
|
||||
// strokes become one arrow (in the same undo step).
|
||||
let (at, shape, rect) = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter {
|
||||
match pair_with_neighbour(s, page, &at)? {
|
||||
Some(arrow) => {
|
||||
// The arrow is the line drawn earlier (or just now): its frame, not the stroke's.
|
||||
s.touch();
|
||||
let r = s.layout().object(&arrow, page).map(|o| o.rect);
|
||||
let rect = r.map_or([fx, fy, fw, fh], |r| [r.x, r.y, r.w, r.h]);
|
||||
(arrow, Some("arrow"), rect)
|
||||
}
|
||||
None => {
|
||||
let shape = convert_at(s, &at)?;
|
||||
(at, shape, [fx, fy, fw, fh])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(at, None, [fx, fy, fw, fh])
|
||||
};
|
||||
Ok(json!({"pos": pos_json(&at), "page": page, "rect": rect, "shape": shape}))
|
||||
}
|
||||
|
||||
/// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph
|
||||
@@ -676,28 +693,141 @@ fn lasso_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
// --- Ink to Shape --------------------------------------------------------------------------
|
||||
|
||||
/// Turn the lasso-selected strokes that look like shapes into those shapes (one undo step).
|
||||
/// Turn the lasso-selected strokes that look like shapes into those shapes, and a selected line
|
||||
/// and arrowhead drawn as two strokes into one arrow (one undo step).
|
||||
fn convert_selection(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let sel = lasso_required(s)?;
|
||||
let mut converted = Vec::new();
|
||||
for pos in &sel.items {
|
||||
let layout = s.layout();
|
||||
let pieces = arrow_pieces(s, &layout, sel.page, |pos, _| sel.items.contains(pos));
|
||||
let (mut used, mut heads, mut converted): (Vec<Pos>, Vec<Pos>, Vec<(Pos, &'static str)>) = (Vec::new(), Vec::new(), Vec::new());
|
||||
// Each piece was recognised once; pairing them is cheap geometry, even for a crowded page.
|
||||
for line in pieces.iter().filter(|k| k.parts.line.is_some()) {
|
||||
for head in pieces.iter().filter(|k| !k.parts.heads.is_empty()) {
|
||||
if line.pos == head.pos || used.contains(&line.pos) || used.contains(&head.pos) || !near(line.rect, head.rect) {
|
||||
continue;
|
||||
}
|
||||
if let Some(arrow) = pair_parts(&line.parts, &head.parts) {
|
||||
set_shape(s, &line.pos, &local_to(&arrow, line.rect))?;
|
||||
used.extend([line.pos.clone(), head.pos.clone()]);
|
||||
heads.push(head.pos.clone());
|
||||
converted.push((line.pos.clone(), "arrow"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for pos in sel.items.iter().filter(|p| !used.contains(p)) {
|
||||
if let Some(name) = convert_at(s, pos)? {
|
||||
converted.push(json!({"pos": pos_json(pos), "shape": name}));
|
||||
converted.push((pos.clone(), name));
|
||||
}
|
||||
}
|
||||
if converted.is_empty() {
|
||||
return Err(CmdError::Failed("none of the selected strokes looks like a shape".into()));
|
||||
}
|
||||
keep_lasso(s, sel);
|
||||
// The heads went into their arrows; last first, so the earlier ones keep their place.
|
||||
heads.sort();
|
||||
for pos in heads.iter().rev() {
|
||||
delete_object(s, pos)?;
|
||||
}
|
||||
let items = sel.items.iter().filter(|p| !heads.contains(p)).map(|p| after_deleting(p, &heads)).collect();
|
||||
let converted: Vec<Value> = converted.iter().map(|(p, name)| json!({"pos": pos_json(&after_deleting(p, &heads)), "shape": name})).collect();
|
||||
keep_lasso(s, LassoSelection { items, ..sel });
|
||||
Ok(json!({"count": converted.len(), "converted": converted}))
|
||||
}
|
||||
|
||||
/// Where `pos` is once the objects at `deleted` (none of them `pos`) are gone.
|
||||
fn after_deleting(pos: &Pos, deleted: &[Pos]) -> Pos {
|
||||
let before = deleted.iter().filter(|d| d.story == pos.story && d.path == pos.path && d.off < pos.off).count();
|
||||
Pos { off: pos.off.saturating_sub(before.saturating_mul(OBJ.len_utf8())), ..pos.clone() }
|
||||
}
|
||||
|
||||
/// A stroke (or an Ink to Shape line or triangle) on a page that may be one half of a two-stroke
|
||||
/// arrow: where it is, when it was drawn (ink's drawing order) and what it can be in an arrow.
|
||||
struct ArrowPiece {
|
||||
pos: Pos,
|
||||
rect: Rect,
|
||||
order: Option<u32>,
|
||||
parts: ArrowParts,
|
||||
}
|
||||
|
||||
/// Whether two objects laid out at `a` and `b` are close enough to make an arrow (a cheap test
|
||||
/// before recognising them).
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
let reach = 0.2 * a.w.max(a.h).max(b.w).max(b.h);
|
||||
reach.is_finite() && a.expand(reach).intersects(&b.expand(reach))
|
||||
}
|
||||
|
||||
/// The pieces of two-stroke arrows on `page` that `keep` (given the position and laid-out rect)
|
||||
/// accepts: unturned, unflipped pen and pencil strokes, and the open two-point lines and closed
|
||||
/// triangles Ink to Shape makes. `keep` runs first, so only kept pieces are recognised.
|
||||
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
|
||||
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for it in &pg.items {
|
||||
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
|
||||
let pos = Pos { story: *story, path: path.clone(), off: *off };
|
||||
if !spin.is_identity() || !keep(&pos, rect) {
|
||||
continue;
|
||||
}
|
||||
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
|
||||
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
|
||||
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
|
||||
if !(pen || shape) || float.rot != 0.0 || float.flip_h || float.flip_v {
|
||||
continue;
|
||||
}
|
||||
let Some((mut pts, closed)) = f.placed(rect.x, rect.y, rect.w, rect.h).into_iter().next() else { continue };
|
||||
if closed && let Some(&first) = pts.first() {
|
||||
pts.push(first);
|
||||
}
|
||||
out.push(ArrowPiece { pos, rect: *rect, order: f.order, parts: arrow_parts(&pts) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ink to Shape while drawing: when the stroke just drawn at `new` and another on the page are a
|
||||
/// line and an arrowhead (either drawn first), make the line an arrow and drop the head. Returns
|
||||
/// where the arrow is.
|
||||
fn pair_with_neighbour(s: &mut Session, page: usize, new: &Pos) -> Result<Option<Pos>, CmdError> {
|
||||
s.touch(); // lay out the new stroke where it was just placed
|
||||
let layout = s.layout();
|
||||
let Some(mine) = layout.object(new, page).map(|o| o.rect) else { return Ok(None) };
|
||||
// Only the new stroke and its neighbours are recognised, not the whole page.
|
||||
let mut pieces = arrow_pieces(s, &layout, page, |pos, rect| pos == new || near(mine, *rect));
|
||||
let Some(at) = pieces.iter().position(|k| k.pos == *new) else { return Ok(None) };
|
||||
let me = pieces.swap_remove(at);
|
||||
// The most recently drawn first (ink records its drawing order; shapes and ink from files
|
||||
// don't, and come after, latest on the page first), so the winner doesn't depend on layout.
|
||||
pieces.sort_by(|a, b| b.order.cmp(&a.order).then_with(|| b.pos.cmp(&a.pos)));
|
||||
for other in &pieces {
|
||||
for (line, head) in [(other, &me), (&me, other)] {
|
||||
if let Some(arrow) = pair_parts(&line.parts, &head.parts) {
|
||||
set_shape(s, &line.pos, &local_to(&arrow, line.rect))?;
|
||||
delete_object(s, &head.pos)?;
|
||||
return Ok(Some(after_deleting(&line.pos, std::slice::from_ref(&head.pos))));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// `shape` (in page points) in the frame of the object laid out at `rect`.
|
||||
fn local_to(shape: &InkShape, rect: Rect) -> InkShape {
|
||||
let m = |[x, y]: [f32; 2]| [x - rect.x, y - rect.y];
|
||||
match shape {
|
||||
InkShape::Arrow { a, b, head_at_end, head_at_start } => {
|
||||
InkShape::Arrow { a: m(*a), b: m(*b), head_at_end: *head_at_end, head_at_start: *head_at_start }
|
||||
}
|
||||
InkShape::Line { a, b } => InkShape::Line { a: m(*a), b: m(*b) },
|
||||
InkShape::Rectangle { x, y, w, h } => InkShape::Rectangle { x: x - rect.x, y: y - rect.y, w: *w, h: *h },
|
||||
InkShape::Ellipse { x, y, w, h } => InkShape::Ellipse { x: x - rect.x, y: y - rect.y, w: *w, h: *h },
|
||||
InkShape::Polygon(c) => InkShape::Polygon(c.iter().copied().map(m).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the ink stroke at `pos` by the shape it looks like, in its colour and thickness and
|
||||
/// where it was drawn. `None` (nothing changed) when it isn't a pen or pencil stroke, it is
|
||||
/// turned or flipped, or it doesn't look like a shape.
|
||||
fn convert_at(s: &mut Session, pos: &Pos) -> Result<Option<&'static str>, CmdError> {
|
||||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||||
let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, extra, .. }) = para.object_at_mut(pos.off) else { return Ok(None) };
|
||||
let Some(InlineObject::Shape { w, h, float, freeform, .. }) = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)) else { return Ok(None) };
|
||||
let Some(f) = freeform.as_deref().filter(|f| matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && f.paths.len() == 1) else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -707,13 +837,24 @@ fn convert_at(s: &mut Session, pos: &Pos) -> Result<Option<&'static str>, CmdErr
|
||||
// The stroke in its frame's own points (the frame may have been resized since).
|
||||
let Some((pts, _)) = f.placed(0.0, 0.0, *w, *h).into_iter().next() else { return Ok(None) };
|
||||
let Some(found) = recognise(&pts) else { return Ok(None) };
|
||||
set_shape(s, pos, &found)?;
|
||||
Ok(Some(found.name()))
|
||||
}
|
||||
|
||||
/// Make the drawing at `pos` the shape `found` (given in the drawing's own frame), keeping its
|
||||
/// colour and thickness, where it is.
|
||||
fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||||
let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, extra, .. }) = para.object_at_mut(pos.off) else {
|
||||
return Err(CmdError::Failed("no drawing there".into()));
|
||||
};
|
||||
let [bx, by, bw, bh] = found.bounds();
|
||||
let (bw, bh) = (bw.max(1.0), bh.max(1.0));
|
||||
let outline = |pts: Vec<[f32; 2]>, closed: bool| {
|
||||
Some(Arc::new(Freeform { w: bw, h: bh, paths: vec![FreePath { pts, closed }], ..Default::default() }.sanitized()))
|
||||
};
|
||||
let local = |c: &[[f32; 2]]| c.iter().map(|[x, y]| [x - bx, y - by]).collect::<Vec<_>>();
|
||||
let (new_kind, geometry) = match &found {
|
||||
let (new_kind, geometry) = match found {
|
||||
InkShape::Rectangle { .. } => (ShapeKind::Rectangle, None),
|
||||
InkShape::Ellipse { .. } => (ShapeKind::Ellipse, None),
|
||||
InkShape::Line { a, b } => (ShapeKind::Freeform, outline(local(&[*a, *b]), false)),
|
||||
@@ -735,7 +876,7 @@ fn convert_at(s: &mut Session, pos: &Pos) -> Result<Option<&'static str>, CmdErr
|
||||
float.x += bx;
|
||||
float.y += by;
|
||||
para.touch();
|
||||
Ok(Some(found.name()))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Ink Replay ----------------------------------------------------------------------------
|
||||
@@ -1017,6 +1158,152 @@ mod tests {
|
||||
assert!(extra.arrow_end && !extra.arrow_start && float.flip_h, "{extra:?} {float:?}");
|
||||
}
|
||||
|
||||
/// Every drawing in the body, in document order.
|
||||
fn drawings(s: &Session) -> Vec<InlineObject> {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter(|o| matches!(o, InlineObject::Shape { .. }))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A straight stroke from (100, 300) to (300, 300).
|
||||
fn shaft_stroke() -> Vec<[f32; 2]> {
|
||||
(0..=40).map(|i| [100.0 + i as f32 * 5.0, 300.0]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_to_shape_turns_a_line_and_a_head_drawn_after_it_into_one_arrow_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
|
||||
let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "C00000", "width": 2})).unwrap();
|
||||
assert_eq!(r["shape"], "line");
|
||||
let steps = s.undo_depth();
|
||||
// An open V at the right-hand end, drawn as a second stroke.
|
||||
let head = [[274.0, 285.0], [287.0, 292.5], [300.0, 300.0], [287.0, 307.5], [274.0, 315.0]];
|
||||
let r = s.run("draw.stroke", &json!({"page": 0, "points": head})).unwrap();
|
||||
assert_eq!(r["shape"], "arrow");
|
||||
assert_eq!(s.undo_depth(), steps + 1, "paired in one undo step");
|
||||
assert!(ink(&s).is_empty(), "the head stroke is gone");
|
||||
let all = drawings(&s);
|
||||
let [InlineObject::Shape { kind, w, stroke, stroke_width, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") };
|
||||
assert_eq!((*kind, *stroke, *stroke_width), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0));
|
||||
assert!(extra.arrow_end && !extra.arrow_start && !float.flip_h && !float.flip_v, "{extra:?} {float:?}");
|
||||
assert!((w - 200.0).abs() < 1.0, "{w}");
|
||||
let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap();
|
||||
assert!(matches!(s.doc.para_at(&at).and_then(|p| p.object_at(at.off)), Some(InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })));
|
||||
let rect = s.layout().object(&at, 0).unwrap().rect;
|
||||
assert!((rect.x - 100.0).abs() < 1.0 && (rect.y - 300.0).abs() < 1.0, "where the line was: {rect:?}");
|
||||
let got: Vec<f32> = r["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect();
|
||||
assert!(
|
||||
got.len() == 4 && [rect.x, rect.y, rect.w, rect.h].iter().zip(&got).all(|(a, b)| (a - b).abs() < 0.5),
|
||||
"the arrow's rect, not the deleted head's: {got:?} vs {rect:?}"
|
||||
);
|
||||
// It survives a DOCX save and open.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
|
||||
assert!(
|
||||
again
|
||||
.iter()
|
||||
.any(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, .. } if extra.arrow_end && !extra.arrow_start)),
|
||||
"{again:?}"
|
||||
);
|
||||
// Undo brings back the line, without the head.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
let all = drawings(&s);
|
||||
let [InlineObject::Shape { kind: ShapeKind::Freeform, extra, .. }] = all.as_slice() else { panic!("the line: {all:?}") };
|
||||
assert!(!extra.arrow_end);
|
||||
assert!(ink(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_to_shape_pairs_a_closed_triangle_head_at_the_start_and_a_head_drawn_first() {
|
||||
let mut s = session();
|
||||
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
|
||||
s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap();
|
||||
// A closed triangle at the left end, where the line began: the arrow points left.
|
||||
let tri = [[100.0, 300.0], [113.0, 292.5], [126.0, 285.0], [126.0, 300.0], [126.0, 315.0], [113.0, 307.5], [100.0, 300.0]];
|
||||
let r = s.run("draw.stroke", &json!({"page": 0, "points": tri})).unwrap();
|
||||
assert_eq!(r["shape"], "arrow");
|
||||
let all = drawings(&s);
|
||||
let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") };
|
||||
assert!(extra.arrow_end && float.flip_h && !float.flip_v, "tip at the left: {extra:?} {float:?}");
|
||||
// Head first (it stays ink on its own), then the shaft that ends at its apex.
|
||||
let mut s = session();
|
||||
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
|
||||
let r = s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap();
|
||||
assert!(r["shape"].is_null());
|
||||
let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap();
|
||||
assert_eq!(r["shape"], "arrow");
|
||||
assert!(ink(&s).is_empty());
|
||||
let all = drawings(&s);
|
||||
let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") };
|
||||
assert!(extra.arrow_end && !float.flip_h, "{extra:?} {float:?}");
|
||||
// A V far from the line's ends stays ink, and the line stays a line.
|
||||
let mut s = session();
|
||||
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
|
||||
s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap();
|
||||
let r = s.run("draw.stroke", &json!({"page": 0, "points": [[174.0, 385.0], [200.0, 400.0], [174.0, 415.0]]})).unwrap();
|
||||
assert!(r["shape"].is_null());
|
||||
assert_eq!(ink(&s).len(), 1);
|
||||
assert!(drawings(&s).iter().all(|o| !matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converting_a_lasso_selection_pairs_a_line_and_a_head_stroke_into_an_arrow() {
|
||||
let mut s = session();
|
||||
s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "0070C0"})).unwrap();
|
||||
s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap();
|
||||
s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 400.0, 120.0, 60.0)})).unwrap();
|
||||
assert_eq!(ink(&s).len(), 3);
|
||||
let steps = s.undo_depth();
|
||||
s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 480], [80, 480]]})).unwrap();
|
||||
let r = s.run("draw.inkToShape", &json!({})).unwrap();
|
||||
let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect();
|
||||
assert!(names.contains(&"arrow") && names.contains(&"rectangle"), "{names:?}");
|
||||
assert!(ink(&s).is_empty(), "the head stroke went into the arrow");
|
||||
let all = drawings(&s);
|
||||
assert_eq!(all.len(), 2, "{all:?}");
|
||||
let arrow = all.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }));
|
||||
let Some(InlineObject::Shape { extra, stroke, .. }) = arrow else { panic!("{all:?}") };
|
||||
assert!(extra.arrow_end && *stroke == Some(wordcraft_doc::Rgb(0, 0x70, 0xC0)));
|
||||
// The selection still holds what's left: deleting it removes both shapes.
|
||||
s.run("draw.lassoDelete", &json!({})).unwrap();
|
||||
assert!(drawings(&s).is_empty());
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(drawings(&s).len(), 2);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ink(&s).len(), 3, "one undo step brings all three strokes back");
|
||||
assert_eq!(s.undo_depth(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converting_a_crowded_lasso_selection_recognises_each_stroke_a_bounded_number_of_times() {
|
||||
let mut s = session();
|
||||
s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap();
|
||||
s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap();
|
||||
// A stack of 40 lines close together: every pair is near, none makes an arrow.
|
||||
let n = 40;
|
||||
for i in 0..n {
|
||||
let y = 420.0 + i as f32 * 2.0;
|
||||
let pts: Vec<[f32; 2]> = (0..=40).map(|k| [100.0 + k as f32 * 5.0, y]).collect();
|
||||
s.run("draw.stroke", &json!({"page": 0, "points": pts})).unwrap();
|
||||
}
|
||||
s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 560], [80, 560]]})).unwrap();
|
||||
super::super::ink_shape::RECOGNISED.with(|c| c.set(0));
|
||||
let r = s.run("draw.inkToShape", &json!({})).unwrap();
|
||||
let calls = super::super::ink_shape::RECOGNISED.with(|c| c.get());
|
||||
let strokes = n + 2;
|
||||
assert!(calls <= 2 * strokes, "{calls} recognitions for {strokes} strokes");
|
||||
let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect();
|
||||
assert_eq!(names.iter().filter(|n| **n == "arrow").count(), 1, "{names:?}");
|
||||
assert_eq!(names.iter().filter(|n| **n == "line").count(), n, "{names:?}");
|
||||
assert!(ink(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_replay_plays_strokes_in_the_order_they_were_drawn() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
//! between its ends; a closed one is an ellipse when its points sit on the ellipse inscribed in
|
||||
//! its bounds (low RMS of the normalised radius), else a polygon from its corners (the closed
|
||||
//! path simplified with Ramer–Douglas–Peucker, near-straight and crowded corners merged) when
|
||||
//! that polygon stays close to every point. An arrow is a line-like shaft plus a head of two barbs
|
||||
//! drawn in the same stroke (shaft first or head first). Anything else (scribbles, curves, letters) stays ink.
|
||||
//! that polygon stays close to every point. An arrow is a line-like shaft plus a head drawn in the
|
||||
//! same stroke (shaft first or head first): two barbs, or a small closed triangle round the tip.
|
||||
//! [`pair_arrow`] makes one arrow of two strokes: a line, and a head (an open V or a small closed
|
||||
//! triangle) whose apex sits at one of its ends. Anything else (scribbles, curves, letters) stays ink.
|
||||
|
||||
use wordcraft_doc::freeform::MAX_POINTS;
|
||||
|
||||
@@ -55,15 +57,17 @@ const MIN_SIZE: f32 = 6.0;
|
||||
/// Most corners a polygon may have.
|
||||
pub const MAX_CORNERS: usize = 8;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times [`recognise`] ran on this thread (tests check the work stays bounded).
|
||||
pub static RECOGNISED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The shape `pts` draws, if it is one.
|
||||
pub fn recognise(pts: &[[f32; 2]]) -> Option<InkShape> {
|
||||
// Finite points only, without repeats.
|
||||
let mut p: Vec<[f32; 2]> = Vec::with_capacity(pts.len().min(MAX_POINTS));
|
||||
for q in pts.iter().take(MAX_POINTS).filter(|[x, y]| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) {
|
||||
if p.last() != Some(q) {
|
||||
p.push(*q);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
RECOGNISED.with(|c| c.set(c.get().saturating_add(1)));
|
||||
let p = clean(pts);
|
||||
let (first, last) = (*p.first()?, *p.last()?);
|
||||
if p.len() < 2 {
|
||||
return None;
|
||||
@@ -83,8 +87,10 @@ pub fn recognise(pts: &[[f32; 2]]) -> Option<InkShape> {
|
||||
return Some(InkShape::Line { a: first, b: last });
|
||||
}
|
||||
}
|
||||
// An arrow: a shaft and a head with both barbs, drawn in one stroke (either end first).
|
||||
if let Some(arrow) = arrow(&p).or_else(|| arrow(&p.iter().rev().copied().collect::<Vec<_>>())) {
|
||||
// An arrow: a shaft and a head (both barbs, or a closed triangle), drawn in one stroke
|
||||
// (either end first).
|
||||
let rev: Vec<[f32; 2]> = p.iter().rev().copied().collect();
|
||||
if let Some(arrow) = arrow(&p).or_else(|| arrow(&rev)).or_else(|| closed_arrow(&p)).or_else(|| closed_arrow(&rev)) {
|
||||
return Some(arrow);
|
||||
}
|
||||
// Other shapes are closed: the stroke ends near where it began and goes around.
|
||||
@@ -126,27 +132,7 @@ const BARB_ANGLE: (f32, f32) = (15.0, 75.0);
|
||||
/// similar angles and lengths. (A stroke drawn head first is tried reversed by the caller.)
|
||||
fn arrow(p: &[[f32; 2]]) -> Option<InkShape> {
|
||||
let tail = *p.first()?;
|
||||
// The tip: start at the first point about as far from the start as any (a return that
|
||||
// overshoots it by a point or two must not move it), then follow the stroke on while it still
|
||||
// moves away from the start (densely sampled strokes have shaft points inside that window).
|
||||
// The first barb heads back, so the walk stops at the real tip and never reaches an overshoot;
|
||||
// jitter of under half a point doesn't stop it early.
|
||||
let dmax = p.iter().map(|q| dist(tail, *q)).fold(0.0, f32::max);
|
||||
let mut ti = p.iter().position(|q| dist(tail, *q) >= dmax - (0.03 * dmax).max(2.0))?;
|
||||
let mut best = dist(tail, *p.get(ti)?);
|
||||
for (i, q) in p.iter().enumerate().skip(ti + 1) {
|
||||
let d = dist(tail, *q);
|
||||
if d >= best {
|
||||
(ti, best) = (i, d);
|
||||
} else if d < best - 0.5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let tip = *p.get(ti)?;
|
||||
let shaft = dist(tail, tip);
|
||||
if shaft < MIN_SIZE || p.get(..=ti)?.iter().any(|q| seg_distance(tail, tip, *q) > (0.06 * shaft).max(1.5)) {
|
||||
return None;
|
||||
}
|
||||
let (ti, tip, shaft) = shaft_tip(p)?;
|
||||
// The head: the stroke after the tip leaves it twice, once for each barb.
|
||||
let head = p.get(ti..)?;
|
||||
let reach = head.iter().map(|q| dist(tip, *q)).fold(0.0, f32::max);
|
||||
@@ -172,20 +158,166 @@ fn arrow(p: &[[f32; 2]]) -> Option<InkShape> {
|
||||
}
|
||||
let &[(l1, b1), (l2, b2)] = barbs.as_slice() else { return None };
|
||||
// The pen returns to the tip between the barbs.
|
||||
if gap > 0.35 * l1.min(l2) {
|
||||
if gap > 0.35 * l1.min(l2) || shaft <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Each barb as a vector from the tip, against the direction back along the shaft.
|
||||
head_fits(tail, tip, tip, b1, b2).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
|
||||
}
|
||||
|
||||
/// An arrow with a closed triangle head, drawn shaft first: the shaft runs from the start to the
|
||||
/// tip like [`arrow`]'s, then the pen goes round a small triangle (tip, one barb end, the other)
|
||||
/// and ends back at the tip. (Drawn head first, the caller tries it reversed.)
|
||||
fn closed_arrow(p: &[[f32; 2]]) -> Option<InkShape> {
|
||||
let tail = *p.first()?;
|
||||
let (ti, tip, _) = shaft_tip(p)?;
|
||||
let head = p.get(ti..)?;
|
||||
// The corners: the point farthest from the tip, then the one farthest from that side.
|
||||
let b1 = head.iter().copied().fold(tip, |m, q| if dist(tip, q) > dist(tip, m) { q } else { m });
|
||||
let b2 = head.iter().copied().fold(tip, |m, q| if seg_distance(tip, b1, q) > seg_distance(tip, b1, m) { q } else { m });
|
||||
let (l1, l2) = (dist(tip, b1), dist(tip, b2));
|
||||
if l1.min(l2) <= 0.0 || dist(*head.last()?, tip) > 0.35 * l1.min(l2) {
|
||||
return None;
|
||||
}
|
||||
// Every point of the head is on the triangle.
|
||||
let tri = [tip, b1, b2];
|
||||
if head.iter().any(|q| polygon_distance(&tri, *q) > (0.15 * l1).max(1.5)) {
|
||||
return None;
|
||||
}
|
||||
head_fits(tail, tip, tip, b1, b2).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
|
||||
}
|
||||
|
||||
/// Whether a head whose barbs run from `apex` to `b1` and `b2` fits a shaft from `tail` to `tip`:
|
||||
/// barbs 5–50 % of the shaft, each 15–75° off the way back along it, on opposite sides of it,
|
||||
/// and of similar length and angle.
|
||||
fn head_fits(tail: [f32; 2], tip: [f32; 2], apex: [f32; 2], b1: [f32; 2], b2: [f32; 2]) -> bool {
|
||||
let shaft = dist(tail, tip);
|
||||
let (l1, l2) = (dist(apex, b1), dist(apex, b2));
|
||||
if !(shaft > 0.0 && l1 > 0.0 && l2 > 0.0) {
|
||||
return false;
|
||||
}
|
||||
// Each barb as a vector from the apex, against the direction back along the shaft.
|
||||
let back = [tail[0] - tip[0], tail[1] - tip[1]];
|
||||
let angle_side = |e: [f32; 2], len: f32| {
|
||||
let u = [e[0] - tip[0], e[1] - tip[1]];
|
||||
let u = [e[0] - apex[0], e[1] - apex[1]];
|
||||
let angle = ((back[0] * u[0] + back[1] * u[1]) / (shaft * len)).clamp(-1.0, 1.0).acos().to_degrees();
|
||||
(angle, back[0] * u[1] - back[1] * u[0])
|
||||
};
|
||||
let ((a1, s1), (a2, s2)) = (angle_side(b1, l1), angle_side(b2, l2));
|
||||
let ok = |l: f32, a: f32| (BARB_LEN.0 * shaft..=BARB_LEN.1 * shaft).contains(&l) && (BARB_ANGLE.0..=BARB_ANGLE.1).contains(&a);
|
||||
let similar = l1.max(l2) <= 2.5 * l1.min(l2) && (a1 - a2).abs() <= 35.0;
|
||||
(ok(l1, a1) && ok(l2, a2) && s1 * s2 < 0.0 && similar).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
|
||||
ok(l1, a1) && ok(l2, a2) && s1 * s2 < 0.0 && similar
|
||||
}
|
||||
|
||||
/// How far a two-stroke arrow's head apex may sit from the line's end, as a fraction of the line.
|
||||
const PAIR_REACH: f32 = 0.12;
|
||||
|
||||
/// What a stroke can be in a two-stroke arrow, worked out once (one recognition): the ends of the
|
||||
/// straight line it is, if it is one, and the ways it can be an arrowhead on its own.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ArrowParts {
|
||||
pub line: Option<([f32; 2], [f32; 2])>,
|
||||
pub heads: Vec<[[f32; 2]; 3]>,
|
||||
}
|
||||
|
||||
/// `pts` as a possible half of a two-stroke arrow (see [`pair_parts`]).
|
||||
pub fn arrow_parts(pts: &[[f32; 2]]) -> ArrowParts {
|
||||
let p = clean(pts);
|
||||
let found = recognise(&p);
|
||||
let line = match found {
|
||||
Some(InkShape::Line { a, b }) => Some((a, b)),
|
||||
_ => None,
|
||||
};
|
||||
ArrowParts { line, heads: heads(&p, found.as_ref()) }
|
||||
}
|
||||
|
||||
/// One arrow from two strokes: `line`, a straight stroke, and `head`, an open V (one barb end,
|
||||
/// the apex, the other end) or a small closed triangle, whose apex is at one end of the line and
|
||||
/// whose barbs point back along it (the single-stroke head limits). The arrow runs from the
|
||||
/// line's other end to that end; `None` when they don't make one.
|
||||
pub fn pair_arrow(line: &[[f32; 2]], head: &[[f32; 2]]) -> Option<InkShape> {
|
||||
pair_parts(&arrow_parts(line), &arrow_parts(head))
|
||||
}
|
||||
|
||||
/// [`pair_arrow`] on strokes already recognised by [`arrow_parts`]: cheap geometry only.
|
||||
pub fn pair_parts(line: &ArrowParts, head: &ArrowParts) -> Option<InkShape> {
|
||||
let (a, b) = line.line?;
|
||||
let mut best: Option<(f32, InkShape)> = None;
|
||||
for &[apex, b1, b2] in &head.heads {
|
||||
for (tail, tip) in [(a, b), (b, a)] {
|
||||
let off = dist(apex, tip);
|
||||
let near = off <= (PAIR_REACH * dist(tail, tip)).max(3.0);
|
||||
if near && best.as_ref().is_none_or(|(d, _)| off < *d) && head_fits(tail, tip, apex, b1, b2) {
|
||||
best = Some((off, InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, arrow)| arrow)
|
||||
}
|
||||
|
||||
/// The ways the cleaned points `p` (which recognise as `found`) can be an arrowhead on their own,
|
||||
/// as `[apex, barb end, barb end]`: each corner of a closed triangle, or the corner of an open V
|
||||
/// whose two arms are straight.
|
||||
fn heads(p: &[[f32; 2]], found: Option<&InkShape>) -> Vec<[[f32; 2]; 3]> {
|
||||
if let Some(InkShape::Polygon(c)) = found
|
||||
&& let &[x, y, z] = c.as_slice()
|
||||
{
|
||||
return vec![[x, y, z], [y, z, x], [z, x, y]];
|
||||
}
|
||||
let (Some(&f), Some(&l)) = (p.first(), p.last()) else { return Vec::new() };
|
||||
// The apex: the point farthest from the chord between the ends.
|
||||
let Some((ai, apex)) = p.iter().copied().enumerate().fold(None, |m: Option<(usize, [f32; 2])>, (i, q)| match m {
|
||||
Some((_, b)) if seg_distance(f, l, b) >= seg_distance(f, l, q) => m,
|
||||
_ => Some((i, q)),
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let straight = |a: [f32; 2], b: [f32; 2], arm: Option<&[[f32; 2]]>| {
|
||||
let len = dist(a, b);
|
||||
len >= 1.0 && arm.is_some_and(|arm| arm.iter().all(|q| seg_distance(a, b, *q) <= (0.12 * len).max(1.5)))
|
||||
};
|
||||
if ai == 0 || ai + 1 >= p.len() || !straight(f, apex, p.get(..=ai)) || !straight(apex, l, p.get(ai..)) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![[apex, f, l]]
|
||||
}
|
||||
|
||||
/// Finite, not absurdly far points only (at most [`MAX_POINTS`]), without repeats.
|
||||
fn clean(pts: &[[f32; 2]]) -> Vec<[f32; 2]> {
|
||||
let mut p: Vec<[f32; 2]> = Vec::with_capacity(pts.len().min(MAX_POINTS));
|
||||
for q in pts.iter().take(MAX_POINTS).filter(|[x, y]| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) {
|
||||
if p.last() != Some(q) {
|
||||
p.push(*q);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A shaft drawn from the start of `p`: the index of its tip, the tip and the shaft's length;
|
||||
/// `None` when it is too short or not straight.
|
||||
fn shaft_tip(p: &[[f32; 2]]) -> Option<(usize, [f32; 2], f32)> {
|
||||
let tail = *p.first()?;
|
||||
// The tip: start at the first point about as far from the start as any (a return that
|
||||
// overshoots it by a point or two must not move it), then follow the stroke on while it still
|
||||
// moves away from the start (densely sampled strokes have shaft points inside that window).
|
||||
// The first barb heads back, so the walk stops at the real tip and never reaches an overshoot;
|
||||
// jitter of under half a point doesn't stop it early.
|
||||
let dmax = p.iter().map(|q| dist(tail, *q)).fold(0.0, f32::max);
|
||||
let mut ti = p.iter().position(|q| dist(tail, *q) >= dmax - (0.03 * dmax).max(2.0))?;
|
||||
let mut best = dist(tail, *p.get(ti)?);
|
||||
for (i, q) in p.iter().enumerate().skip(ti + 1) {
|
||||
let d = dist(tail, *q);
|
||||
if d >= best {
|
||||
(ti, best) = (i, d);
|
||||
} else if d < best - 0.5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let tip = *p.get(ti)?;
|
||||
let shaft = dist(tail, tip);
|
||||
if shaft < MIN_SIZE || p.get(..=ti)?.iter().any(|q| seg_distance(tail, tip, *q) > (0.06 * shaft).max(1.5)) {
|
||||
return None;
|
||||
}
|
||||
Some((ti, tip, shaft))
|
||||
}
|
||||
|
||||
/// Left, top, width and height of `pts` (zero for none).
|
||||
@@ -686,4 +818,153 @@ mod tests {
|
||||
let b = tip_of(&stroke);
|
||||
assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot head first: {b:?}");
|
||||
}
|
||||
|
||||
/// A shaft from `tail` to `tip`, then a closed triangle head drawn round the tip in the same
|
||||
/// stroke (tip, one barb end, the other, back to the tip); `n` shaft points, `m` per side.
|
||||
fn closed_arrow_n(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32, n: usize, m: usize) -> Vec<[f32; 2]> {
|
||||
let [b1, b2] = barb_ends(tail, tip, barb, spread_deg);
|
||||
let mut pts = line(tail, tip, n);
|
||||
pts.extend(line(tip, b1, m));
|
||||
pts.extend(line(b1, b2, m));
|
||||
pts.extend(line(b2, tip, m));
|
||||
pts.push(tip);
|
||||
pts
|
||||
}
|
||||
|
||||
/// The ends of a head's two barbs at `tip`, `barb` long, `spread_deg` either side of the shaft.
|
||||
fn barb_ends(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> [[f32; 2]; 2] {
|
||||
let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]);
|
||||
let end = |sign: f32| {
|
||||
let a = back + sign * spread_deg.to_radians();
|
||||
[tip[0] + barb * a.cos(), tip[1] + barb * a.sin()]
|
||||
};
|
||||
[end(1.0), end(-1.0)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_with_a_closed_triangle_head_in_one_stroke_are_recognised_both_ways() {
|
||||
for (tail, tip) in
|
||||
[([20.0, 100.0], [180.0, 100.0]), ([180.0, 100.0], [20.0, 100.0]), ([100.0, 180.0], [100.0, 20.0]), ([150.0, 120.0], [30.0, 30.0])]
|
||||
{
|
||||
for stroke in [closed_arrow_n(tail, tip, 25.0, 30.0, 24, 5), closed_arrow_n(tail, tip, 20.0, 25.0, dist(tail, tip).round() as usize, 15)]
|
||||
{
|
||||
let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = recognise(&stroke) else {
|
||||
panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke))
|
||||
};
|
||||
assert!(head_at_end && !head_at_start);
|
||||
assert!(dist(a, tail) < 2.0 && dist(b, tip) < 2.0, "{a:?} {b:?} for {tail:?} {tip:?}");
|
||||
// The loop round the head first, then the shaft: the same arrow.
|
||||
let mut rev = stroke;
|
||||
rev.reverse();
|
||||
let Some(InkShape::Arrow { a, b, .. }) = recognise(&rev) else { panic!("head first {tail:?}->{tip:?}: {:?}", recognise(&rev)) };
|
||||
assert!(dist(a, tail) < 2.0 && dist(b, tip) < 2.0, "head first {a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
// The head size limits hold: 5 to 50 % of a 160 pt shaft, 15 to 75 degrees.
|
||||
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
|
||||
let is_arrow = |l: f32, deg: f32| matches!(recognise(&closed_arrow_n(tail, tip, l, deg, 24, 5)), Some(InkShape::Arrow { .. }));
|
||||
assert!(is_arrow(12.0, 30.0) && is_arrow(70.0, 30.0) && is_arrow(25.0, 20.0) && is_arrow(25.0, 70.0));
|
||||
assert!(!is_arrow(6.0, 30.0) && !is_arrow(90.0, 30.0) && !is_arrow(25.0, 8.0) && !is_arrow(25.0, 85.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangles_without_a_shaft_stay_triangles() {
|
||||
// Closed, and closed with the pen running on past the start along the first side.
|
||||
let tri = around(&[[0.0, 80.0], [45.0, 0.0], [90.0, 80.0]], 15);
|
||||
assert_eq!(recognise(&tri).map(|s| s.name()), Some("triangle"));
|
||||
let mut over = tri.clone();
|
||||
over.extend(line([0.0, 80.0], [45.0, 0.0], 15).into_iter().skip(1).take(8));
|
||||
assert!(!matches!(recognise(&over), Some(InkShape::Arrow { .. })), "{:?}", recognise(&over));
|
||||
// A long thin triangle is not a shaft with a head.
|
||||
let thin = around(&[[0.0, 0.0], [160.0, 10.0], [0.0, 20.0]], 20);
|
||||
assert!(!matches!(recognise(&thin), Some(InkShape::Arrow { .. })), "{:?}", recognise(&thin));
|
||||
// A shaft with a triangle as big as the shaft is not an arrow.
|
||||
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
|
||||
assert!(!matches!(recognise(&closed_arrow_n(tail, tip, 150.0, 30.0, 24, 5)), Some(InkShape::Arrow { .. })));
|
||||
// Hostile points around a closed head never panic.
|
||||
let mut junk = closed_arrow_n(tail, tip, 25.0, 30.0, 24, 5);
|
||||
junk.insert(30, [f32::NAN, f32::INFINITY]);
|
||||
assert!(matches!(recognise(&junk), Some(InkShape::Arrow { .. })));
|
||||
}
|
||||
|
||||
/// A head drawn on its own: an open V (one barb end, the apex, the other end) or a closed
|
||||
/// triangle (apex, barb end, barb end, apex), `m` points per side.
|
||||
fn v_head(apex: [f32; 2], [b1, b2]: [[f32; 2]; 2], m: usize) -> Vec<[f32; 2]> {
|
||||
let mut pts = line(b1, apex, m);
|
||||
pts.extend(line(apex, b2, m));
|
||||
pts.push(b2);
|
||||
pts
|
||||
}
|
||||
|
||||
fn triangle_head(apex: [f32; 2], [b1, b2]: [[f32; 2]; 2], m: usize) -> Vec<[f32; 2]> {
|
||||
let mut pts = line(apex, b1, m);
|
||||
pts.extend(line(b1, b2, m));
|
||||
pts.extend(line(b2, apex, m));
|
||||
pts.push(apex);
|
||||
pts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_head_stroke_at_a_line_end_pairs_into_an_arrow() {
|
||||
for (tail, tip) in [([20.0, 100.0], [180.0, 100.0]), ([180.0, 160.0], [40.0, 30.0]), ([100.0, 20.0], [100.0, 180.0])] {
|
||||
let shaft = line(tail, tip, 40).into_iter().chain([tip]).collect::<Vec<_>>();
|
||||
let ends = barb_ends(tail, tip, 25.0, 30.0);
|
||||
for (what, head) in [("V", v_head(tip, ends, 8)), ("triangle", triangle_head(tip, ends, 8)), ("bare V", vec![ends[0], tip, ends[1]])] {
|
||||
let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = pair_arrow(&shaft, &head) else {
|
||||
panic!("{what} {tail:?}->{tip:?}: {:?}", pair_arrow(&shaft, &head))
|
||||
};
|
||||
assert!(head_at_end && !head_at_start);
|
||||
assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "{what}: {a:?} {b:?}");
|
||||
// Drawn the other way round, the head is still found at its end.
|
||||
let mut rev = head.clone();
|
||||
rev.reverse();
|
||||
assert!(matches!(pair_arrow(&shaft, &rev), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "{what} reversed");
|
||||
let mut back = shaft.clone();
|
||||
back.reverse();
|
||||
assert!(matches!(pair_arrow(&back, &head), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "{what}, shaft drawn tip first");
|
||||
}
|
||||
// A triangle given as its corners (an Ink to Shape triangle), apex anywhere in the ring.
|
||||
let [b1, b2] = barb_ends(tail, tip, 25.0, 30.0);
|
||||
assert!(matches!(pair_arrow(&shaft, &[b1, b2, tip, b1]), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "corners");
|
||||
// An apex a few points short of the end still pairs.
|
||||
let d = [(tip[0] - tail[0]) / dist(tail, tip), (tip[1] - tail[1]) / dist(tail, tip)];
|
||||
let near = [tip[0] - 6.0 * d[0] + 3.0 * d[1], tip[1] - 6.0 * d[1] - 3.0 * d[0]];
|
||||
assert!(pair_arrow(&shaft, &v_head(near, barb_ends(tail, near, 25.0, 30.0), 8)).is_some(), "near the end");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heads_away_from_the_end_the_wrong_way_or_the_wrong_size_dont_pair() {
|
||||
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
|
||||
let shaft = line(tail, tip, 40).into_iter().chain([tip]).collect::<Vec<_>>();
|
||||
let head_at = |apex: [f32; 2], barb: f32, deg: f32| v_head(apex, barb_ends(tail, apex, barb, deg), 8);
|
||||
// Far from either end: mid-shaft, and 40 pt past the tip.
|
||||
assert_eq!(pair_arrow(&shaft, &head_at([100.0, 100.0], 25.0, 30.0)), None);
|
||||
assert_eq!(pair_arrow(&shaft, &head_at([220.0, 100.0], 25.0, 30.0)), None);
|
||||
assert_eq!(pair_arrow(&shaft, &head_at([180.0, 140.0], 25.0, 30.0)), None);
|
||||
// Pointing back at the shaft (barbs forward), too big, too small, too narrow, too wide.
|
||||
let forward = v_head(tip, barb_ends([340.0, 100.0], tip, 25.0, 30.0), 8);
|
||||
assert_eq!(pair_arrow(&shaft, &forward), None);
|
||||
for (barb, deg) in [(100.0, 30.0), (5.0, 30.0), (25.0, 5.0), (25.0, 85.0)] {
|
||||
assert_eq!(pair_arrow(&shaft, &head_at(tip, barb, deg)), None, "barb {barb} at {deg} degrees");
|
||||
}
|
||||
// Lopsided: both barbs on one side.
|
||||
assert_eq!(pair_arrow(&shaft, &[[155.0, 87.0], tip, [158.0, 80.0]]), None);
|
||||
// The "line" must be a line, the head a head.
|
||||
let ends = barb_ends(tail, tip, 25.0, 30.0);
|
||||
let zig: Vec<[f32; 2]> = (0..9).map(|i| [20.0 + i as f32 * 20.0, if i % 2 == 0 { 100.0 } else { 120.0 }]).collect();
|
||||
assert_eq!(pair_arrow(&zig, &v_head(tip, ends, 8)), None);
|
||||
assert_eq!(pair_arrow(&shaft, &[[150.0, 80.0], [170.0, 120.0], [175.0, 80.0], [180.0, 120.0]]), None, "a scribble");
|
||||
assert_eq!(pair_arrow(&shaft, &around(&[[150.0, 60.0], [190.0, 60.0], [190.0, 140.0], [150.0, 140.0]], 8)), None, "a box");
|
||||
// Hostile input never panics.
|
||||
let mut junk = v_head(tip, ends, 8);
|
||||
junk.insert(2, [f32::NAN, 1.0]);
|
||||
junk.push([f32::INFINITY, 0.0]);
|
||||
assert!(pair_arrow(&shaft, &junk).is_some());
|
||||
assert_eq!(pair_arrow(&[], &[]), None);
|
||||
assert_eq!(pair_arrow(&shaft, &[[180.0, 100.0]]), None);
|
||||
let huge: Vec<[f32; 2]> = (0..MAX_POINTS * 2).map(|i| [(i % 977) as f32, ((i * 31) % 541) as f32]).collect();
|
||||
let _ = pair_arrow(&huge, &huge);
|
||||
let _ = pair_arrow(&shaft, &huge);
|
||||
}
|
||||
}
|
||||
@@ -46,11 +46,11 @@ fn para_props(s: &Session) -> ParaProps {
|
||||
fn char_props(s: &Session) -> CharProps {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
return s.typing_props();
|
||||
return s.caret_props();
|
||||
}
|
||||
match s.doc.para_at(&a) {
|
||||
Some(p) if a.off < p.len() => p.props_of_char(a.off).clone(),
|
||||
_ => s.typing_props(),
|
||||
_ => s.caret_props(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("list.get", "List Definition", "Home › Paragraph › Multilevel List", get)
|
||||
.params(r#"{"num"?: list id (default: the caret's list)} → {"num", "levels": [as list.define]}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("list.multilevelPreset", "Multilevel List Library", "Home › Paragraph › Multilevel List", preset).params(
|
||||
r#"{"preset": "headings" (1 / 1.1 / 1.1.1) | "article" (Article I. / Section 1.01 / (a)) | "outlineHeadings" (I. / A. / 1.) | "chapter" (Chapter 1, lower levels unnumbered)} — a new list whose nine levels are linked to Heading 1–9: every paragraph in those styles is numbered, selected paragraphs already in a Heading style keep it, and any other selected paragraph becomes Heading 1 (the first level)"#,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -226,6 +229,96 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The heading-linked lists of the Multilevel List gallery's library, by preset id: level `i`
|
||||
/// is linked to Heading `i + 1`.
|
||||
fn preset_levels(preset: &str) -> Option<Vec<Level>> {
|
||||
use NumFormat as F;
|
||||
let levels = (0..LEVELS)
|
||||
.map(|i| {
|
||||
let n = i + 1;
|
||||
// Numbers start at the margin; each level's text a little further in.
|
||||
let at = |indent: f32| Level { indent, hanging: indent, style: Some(format!("Heading{n}")), ..Level::default() };
|
||||
let (format, text, indent) = match preset {
|
||||
"headings" => (F::Decimal, (1..=n).map(|k| format!("%{k}")).collect::<Vec<_>>().join("."), 21.6 + 7.2 * i as f32),
|
||||
"article" => match i {
|
||||
0 => (F::UpperRoman, "Article %1.".to_string(), 0.0),
|
||||
1 => (F::DecimalZero, "Section %1.%2".to_string(), 0.0),
|
||||
_ => {
|
||||
let f = [F::LowerLetter, F::LowerRoman, F::Decimal, F::LowerLetter, F::LowerRoman, F::LowerLetter, F::LowerRoman];
|
||||
let t = if i < 4 {
|
||||
format!("(%{n})")
|
||||
} else if i < 7 {
|
||||
format!("%{n})")
|
||||
} else {
|
||||
format!("%{n}.")
|
||||
};
|
||||
(f.get(i - 2).copied().unwrap_or(F::Decimal), t, 36.0 + 7.2 * (i - 2) as f32)
|
||||
}
|
||||
},
|
||||
"outlineHeadings" => {
|
||||
let f = [
|
||||
F::UpperRoman,
|
||||
F::UpperLetter,
|
||||
F::Decimal,
|
||||
F::LowerLetter,
|
||||
F::Decimal,
|
||||
F::LowerLetter,
|
||||
F::LowerRoman,
|
||||
F::LowerLetter,
|
||||
F::LowerRoman,
|
||||
];
|
||||
let t = match i {
|
||||
0..=2 => format!("%{n}."),
|
||||
3 => format!("%{n})"),
|
||||
_ => format!("(%{n})"),
|
||||
};
|
||||
(f.get(i).copied().unwrap_or(F::Decimal), t, 36.0 * n as f32)
|
||||
}
|
||||
"chapter" if i == 0 => (F::Decimal, "Chapter %1".to_string(), 0.0),
|
||||
"chapter" => (F::None, String::new(), 0.0),
|
||||
_ => return None,
|
||||
};
|
||||
// "Section 1.01" shows the article number in digits (legal numbering).
|
||||
Some(Level { format, text, legal: preset == "article" && i == 1, ..at(indent) })
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(levels)
|
||||
}
|
||||
|
||||
/// A Multilevel List gallery preset: a new list linked to Heading 1–9. The heading styles carry
|
||||
/// the number, so every heading (and every heading added later) is numbered. Paragraphs in a
|
||||
/// Heading style keep their level; any other paragraph under the caret or selection (Normal,
|
||||
/// Quote, Title…) becomes Heading 1, as in Word.
|
||||
fn preset(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = p::req_str(v, "preset")?;
|
||||
let levels = preset_levels(id).ok_or_else(|| CmdError::Params("`preset` must be headings, article, outlineHeadings or chapter".into()))?;
|
||||
let num = s.doc.numbering.try_add_levels(None, levels)?;
|
||||
let mut linked = Vec::new();
|
||||
for n in 1..=LEVELS as u8 {
|
||||
let id = super::para::ensure_heading_style(s, n);
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
st.para.numbering = Some(NumRef { num, level: n - 1 });
|
||||
linked.push(id);
|
||||
}
|
||||
}
|
||||
touch_styles(s, &linked)?;
|
||||
super::para::fmt(s, &|pp| {
|
||||
// A heading keeps its style and level; any other paragraph (Normal, Quote, Title…)
|
||||
// becomes the first level's heading, Heading 1. The number comes from the style.
|
||||
if !pp.style.as_ref().is_some_and(|st| linked.contains(st)) {
|
||||
pp.style = linked.first().cloned();
|
||||
}
|
||||
pp.numbering = None;
|
||||
pp.indent_left = None;
|
||||
pp.indent_first = None;
|
||||
pp.indent_left_chars = None;
|
||||
pp.indent_first_chars = None;
|
||||
})?;
|
||||
let mut r = sel_result(s)?;
|
||||
r["num"] = json!(num);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Lay out again every paragraph in one of `nums`.
|
||||
fn touch_lists(s: &mut Session, nums: &[u32]) -> Result<(), CmdError> {
|
||||
touch_where(s, &|p| p.props.numbering.is_some_and(|n| nums.contains(&n.num)))
|
||||
@@ -309,4 +402,138 @@ mod tests {
|
||||
// Programmatic calls without levels are an error, not a dialog.
|
||||
assert!(s.run("list.define", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Each body paragraph's number as laid out (`None`: no number), and its style.
|
||||
fn numbered(s: &mut Session) -> Vec<(Option<String>, String)> {
|
||||
let l = s.layout();
|
||||
let labels: Vec<Option<String>> = l
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.items)
|
||||
.filter_map(|i| if let Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None })
|
||||
.collect();
|
||||
let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into()));
|
||||
labels.into_iter().zip(styles).collect()
|
||||
}
|
||||
|
||||
fn n(label: Option<&str>, style: &str) -> (Option<String>, String) {
|
||||
(label.map(str::to_string), style.to_string())
|
||||
}
|
||||
|
||||
/// Paragraphs "One" (Heading 1), "Two" (Heading 2), "Three" (`third`), the caret in "One".
|
||||
fn headings(third: &str) -> Session {
|
||||
let mut s = Session::new(Document::new());
|
||||
for (i, (t, st)) in [("One", "Heading1"), ("Two", "Heading2"), ("Three", third)].iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
}
|
||||
s.run("text.insert", &json!({"text": t})).unwrap();
|
||||
s.run("para.style", &json!({"style": st})).unwrap();
|
||||
}
|
||||
s.sel = crate::Selection::caret(wordcraft_doc::Pos::body(0, 0));
|
||||
s
|
||||
}
|
||||
|
||||
/// #1793: the Multilevel List gallery's heading presets number headings through their styles
|
||||
/// (Word: One=1, Two=1.1, Three=2), keep the styles, and survive a save to .docx and back.
|
||||
#[test]
|
||||
fn a_heading_preset_numbers_headings_and_keeps_their_styles() {
|
||||
let mut s = headings("Heading1");
|
||||
s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap();
|
||||
let want = [n(Some("1"), "Heading1"), n(Some("1.1"), "Heading2"), n(Some("2"), "Heading1")];
|
||||
assert_eq!(numbered(&mut s), want);
|
||||
// The number comes from the style, so a heading added later is numbered too.
|
||||
let h2 = s.doc.styles.get("Heading2").and_then(|st| st.para.numbering).unwrap();
|
||||
assert_eq!(h2.level, 1);
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(back.styles.get("Heading2").and_then(|st| st.para.numbering).map(|x| x.level), Some(1), "numPr on the style");
|
||||
let mut b = Session::new(back);
|
||||
assert_eq!(numbered(&mut b), want);
|
||||
}
|
||||
|
||||
/// #1793: the other heading presets of Word's List Library, level by level.
|
||||
#[test]
|
||||
fn every_heading_preset_numbers_three_heading_levels() {
|
||||
for (preset, want) in [
|
||||
("headings", [Some("1"), Some("1.1"), Some("1.1.1")]),
|
||||
("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]),
|
||||
("outlineHeadings", [Some("I."), Some("A."), Some("1.")]),
|
||||
("chapter", [Some("Chapter 1"), None, None]),
|
||||
] {
|
||||
let mut s = headings("Heading3");
|
||||
s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap();
|
||||
let got: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
|
||||
assert_eq!(got, want.map(|x| x.map(str::to_string)), "{preset}");
|
||||
}
|
||||
let mut s = headings("Heading3");
|
||||
assert!(s.run("list.multilevelPreset", &json!({"preset": "nope"})).is_err());
|
||||
assert!(s.run("list.multilevelPreset", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Paragraphs (text, style id), the selection from paragraph `from` to the end of `to`.
|
||||
fn doc(paras: &[(&str, &str)], from: usize, to: usize) -> Session {
|
||||
let mut s = Session::new(Document::new());
|
||||
for (i, (t, st)) in paras.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
}
|
||||
s.run("text.insert", &json!({"text": t})).unwrap();
|
||||
s.run("para.style", &json!({"style": st})).unwrap();
|
||||
}
|
||||
let end = paras.get(to).map_or(0, |p| p.0.len());
|
||||
s.sel = crate::Selection { anchor: wordcraft_doc::Pos::body(from, 0), focus: wordcraft_doc::Pos::body(to, end) };
|
||||
s
|
||||
}
|
||||
|
||||
/// #1793 (Word for Mac 16.113.4, 2026-10-16): with a caret or a selection, a heading preset
|
||||
/// leaves paragraphs already in a Heading style at their own style and level; every other
|
||||
/// selected paragraph (Normal, Quote, Title…) becomes Heading 1.
|
||||
#[test]
|
||||
fn a_heading_preset_keeps_headings_and_makes_other_paragraphs_heading_1() {
|
||||
let (h1, h2, h3) = ("Heading1", "Heading2", "Heading3");
|
||||
let cases: Vec<(Vec<(&str, &str)>, usize, usize, Vec<(Option<&str>, &str)>)> = vec![
|
||||
// All selected.
|
||||
(vec![("One", h1), ("Two", h2), ("Three", h3)], 0, 2, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("1.1.1"), h3)]),
|
||||
// No Heading 1 above.
|
||||
(vec![("Two", h2), ("Three", h3)], 0, 1, vec![(Some("1.1"), h2), (Some("1.1.1"), h3)]),
|
||||
(vec![("Body", "Normal"), ("Three", h3)], 0, 1, vec![(Some("1"), h1), (Some("1.1.1"), h3)]),
|
||||
(vec![("One", h1), ("Body", "Normal"), ("Two", h2)], 0, 2, vec![(Some("1"), h1), (Some("2"), h1), (Some("2.1"), h2)]),
|
||||
(vec![("One", h1), ("Two", h2), ("Body", "Normal")], 0, 2, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("2"), h1)]),
|
||||
// The caret in Two.
|
||||
(vec![("One", h1), ("Two", h2), ("Three", h3)], 1, 1, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("1.1.1"), h3)]),
|
||||
(vec![("Quote", "Quote"), ("Two", h2)], 0, 1, vec![(Some("1"), h1), (Some("1.1"), h2)]),
|
||||
(vec![("Title", "Title")], 0, 0, vec![(Some("1"), h1)]),
|
||||
// Body text after the selection stays unnumbered.
|
||||
(vec![("One", h1), ("After", "Normal")], 0, 0, vec![(Some("1"), h1), (None, "Normal")]),
|
||||
];
|
||||
for (paras, from, to, want) in cases {
|
||||
let mut s = doc(¶s, from, to);
|
||||
s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap();
|
||||
let want: Vec<_> = want.into_iter().map(|(l, st)| n(l, st)).collect();
|
||||
assert_eq!(numbered(&mut s), want, "{paras:?} {from}..={to}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1793 (Word for Mac): the caret alone in a Normal paragraph makes it Heading 1 numbered 1.
|
||||
#[test]
|
||||
fn a_heading_preset_on_a_lone_normal_paragraph_makes_it_heading_1() {
|
||||
let mut s = doc(&[("Body", "Normal")], 0, 0);
|
||||
s.sel = crate::Selection::caret(wordcraft_doc::Pos::body(0, 2));
|
||||
s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap();
|
||||
assert_eq!(numbered(&mut s), [n(Some("1"), "Heading1")]);
|
||||
}
|
||||
|
||||
/// #1793: one Undo takes the preset back: paragraph styles and the heading styles' numbers.
|
||||
#[test]
|
||||
fn undo_takes_a_heading_preset_back_in_one_step() {
|
||||
let mut s = doc(&[("Body", "Normal"), ("Two", "Heading2")], 0, 1);
|
||||
let before = numbered(&mut s);
|
||||
s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap();
|
||||
assert_ne!(numbered(&mut s), before);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(numbered(&mut s), before);
|
||||
for n in 1..=9 {
|
||||
assert_eq!(s.doc.styles.get(&format!("Heading{n}")).and_then(|st| st.para.numbering), None, "Heading{n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,10 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
|
||||
delete_selection(s)?;
|
||||
// Typed text is new: never deleted, and not a formatting change of the text around it.
|
||||
props.fmt_change = None;
|
||||
// Its direction comes from its own letters, as on screen, not from the run it's typed into:
|
||||
// Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX
|
||||
// writer marks the right-to-left pieces itself.
|
||||
props.rtl = None;
|
||||
if s.doc.settings.track_changes {
|
||||
props.ins = Some(new_revision(s, RevisionKind::Insert));
|
||||
props.del = None;
|
||||
|
||||
@@ -53,7 +53,7 @@ fn in_use(s: &Session, rev: u32) -> bool {
|
||||
/// done) when it isn't such a cut.
|
||||
pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if !s.doc.settings.track_changes || a == b || a.story != b.story || !move_sized(&s.clipboard_text) {
|
||||
if !s.doc.settings.track_changes || !s.doc.settings.track_moves || a == b || a.story != b.story || !move_sized(&s.clipboard_text) {
|
||||
return Ok(false);
|
||||
}
|
||||
s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso()));
|
||||
@@ -71,7 +71,7 @@ pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
|
||||
/// returns the revision for the pasted text (the destination). `None` = an ordinary insertion.
|
||||
pub fn take_paste(s: &mut Session, frag: &Fragment) -> Option<u32> {
|
||||
let m = s.pending_move.take()?;
|
||||
if m.author != s.author || frag.plain_text() != m.text || !in_use(s, m.rev) {
|
||||
if !s.doc.settings.track_moves || m.author != s.author || frag.plain_text() != m.text || !in_use(s, m.rev) {
|
||||
return None;
|
||||
}
|
||||
let src = s.doc.revisions.get(m.rev as usize).filter(|r| r.kind == RevisionKind::Delete && r.move_name.is_none())?;
|
||||
@@ -240,6 +240,25 @@ mod tests {
|
||||
assert_eq!(k, ["delete", "insert"]);
|
||||
}
|
||||
|
||||
/// Gap #5: with Track moves off (`w:doNotTrackMoves`), a tracked cut and paste of a
|
||||
/// paragraph is an ordinary deletion and insertion, with no move names.
|
||||
#[test]
|
||||
fn with_track_moves_off_a_cut_and_paste_is_a_deletion_and_an_insertion() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("review.trackingOptions", &json!({"trackMoves": false})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap();
|
||||
s.run("edit.cut", &json!({})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap();
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["Second one.", "First one.", "Second one.", "Third one."]);
|
||||
assert!(s.doc.revisions.iter().all(|r| r.move_name.is_none()), "{:?}", s.doc.revisions);
|
||||
let k = kinds(&mut s);
|
||||
assert!(k.iter().all(|(k, m)| (k == "insert" || k == "delete") && m.is_null()), "{k:?}");
|
||||
assert!(k.iter().any(|(k, _)| k == "insert") && k.iter().any(|(k, _)| k == "delete"), "{k:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepting_one_end_of_a_move_accepts_both() {
|
||||
let mut s = moved();
|
||||
|
||||
@@ -474,7 +474,9 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult {
|
||||
)
|
||||
}
|
||||
};
|
||||
let gutter = p::f32(v, "gutter");
|
||||
// Negative margins count as zero before the fit check, so one can't offset another.
|
||||
let (t, b, l, r) = (t.max(0.0), b.max(0.0), l.max(0.0), r.max(0.0));
|
||||
let gutter = p::f32(v, "gutter").map(|g| g.max(0.0));
|
||||
let cur = sect(s);
|
||||
if !margins_fit((cur.page_w, cur.page_h), (t, b, l, r), gutter.unwrap_or(cur.gutter)) {
|
||||
return Err(CmdError::Params("margins leave no room for text".into()));
|
||||
@@ -485,12 +487,12 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.doc.settings.mirror_margins = on;
|
||||
}
|
||||
with_sect(s, |x| {
|
||||
x.margin_top = t.max(0.0);
|
||||
x.margin_bottom = b.max(0.0);
|
||||
x.margin_left = l.max(0.0);
|
||||
x.margin_right = r.max(0.0);
|
||||
x.margin_top = t;
|
||||
x.margin_bottom = b;
|
||||
x.margin_left = l;
|
||||
x.margin_right = r;
|
||||
if let Some(g) = gutter {
|
||||
x.gutter = g.max(0.0);
|
||||
x.gutter = g;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -465,10 +465,13 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return super::format::apply(s, &|c| c.style = Some(id.clone()));
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
// A style linked to a list level (a heading in a numbered-headings list) brings its number.
|
||||
let numbered = s.doc.styles.resolve_para(&ParaProps { style: Some(id.clone()), ..Default::default() }).numbering.is_some();
|
||||
fmt(s, &|p| {
|
||||
p.style = Some(id.clone());
|
||||
// Applying a heading or Normal removes direct list numbering (except list styles).
|
||||
if (id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0) {
|
||||
// Applying a heading or Normal removes direct list numbering (except list styles); a
|
||||
// numbered style's own number replaces any direct numbering, even numbering switched off.
|
||||
if numbered || ((id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0)) {
|
||||
p.numbering = None;
|
||||
}
|
||||
})?;
|
||||
@@ -755,7 +758,7 @@ pub(crate) fn set_heading(s: &mut Session, story: wordcraft_doc::StoryRef, path:
|
||||
|
||||
/// Make sure the built-in `Heading{n}` style (and its linked character style) is in the
|
||||
/// document, for files that didn't bring it.
|
||||
fn ensure_heading_style(s: &mut Session, n: u8) -> String {
|
||||
pub(crate) fn ensure_heading_style(s: &mut Session, n: u8) -> String {
|
||||
let id = format!("Heading{n}");
|
||||
if s.doc.styles.get(&id).is_none() {
|
||||
let builtin = wordcraft_doc::styles::StyleSheet::builtin();
|
||||
|
||||
@@ -74,11 +74,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption)
|
||||
.params(r#"{"label"?: "Figure|Table|Equation|any label", "text"?: string, "position"?: "below|above", "excludeLabel"?: bool, "format"?: "ARABIC|alphabetic|ALPHABETIC|roman|ROMAN", "chapter"?: heading level 1-9, "separator"?: "-|.|:|—|–"}"#),
|
||||
// F9: update fields; the selected ones that showed their code show their result again.
|
||||
CommandSpec::new("references.updateFields", "Update Field", "References", |s, _| {
|
||||
update_fields(s)?;
|
||||
CommandSpec::new("references.updateFields", "Update Field", "References", |s, v| {
|
||||
update_fields_in(s, v.get("all").and_then(Value::as_bool).unwrap_or(false))?;
|
||||
super::fields::show_results(s)?;
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"all"?: bool (the body and every header, footer, note and text box, wherever the caret is; default: the story the caret is in)}"#)
|
||||
.key("F9"),
|
||||
]
|
||||
}
|
||||
@@ -861,9 +862,22 @@ fn has_switch(instr: &str, sw: char) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Update fields in the body: dates, SEQ numbering, cross-references, TOC.
|
||||
/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, TITLE, AUTHOR
|
||||
/// and NUMWORDS in the body, or in the header, footer, note or text box the caret is in (the body's
|
||||
/// fields stay as they are then). SEQ numbering, citations and TOC are body-only, so they run only
|
||||
/// when the caret is in the body.
|
||||
pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
update_seq(s)?;
|
||||
update_fields_in(s, false)
|
||||
}
|
||||
|
||||
/// [`update_fields`], or with `all` the whole document: the body (with SEQ, citations and TOC) and
|
||||
/// every part story, wherever the caret is (what printing needs).
|
||||
pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
let caret_story = s.sel.focus.story;
|
||||
let in_body = all || caret_story == StoryRef::Body;
|
||||
if in_body {
|
||||
update_seq(s)?;
|
||||
}
|
||||
let mut pages: std::collections::HashMap<String, (u32, NumFormat)> = Default::default();
|
||||
let formats: Vec<NumFormat> = s.doc.sections().into_iter().map(|(_, sec)| sec.page_num_format).collect();
|
||||
let marks = s.doc.bookmarks();
|
||||
@@ -879,8 +893,10 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
.unwrap_or((1, NumFormat::Decimal));
|
||||
pages.entry(name).or_insert(page_info);
|
||||
}
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
let stories: Vec<StoryRef> =
|
||||
if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] };
|
||||
for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::<Vec<_>>() {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
|
||||
continue;
|
||||
}
|
||||
@@ -933,7 +949,7 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &path)?;
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
for (k, r) in updates {
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
|
||||
@@ -943,6 +959,9 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
}
|
||||
para.touch();
|
||||
}
|
||||
if !in_body {
|
||||
return Ok(());
|
||||
}
|
||||
super::citations::update_citations(s)?;
|
||||
update_toc(s)
|
||||
}
|
||||
@@ -1198,4 +1217,83 @@ mod tests {
|
||||
assert_eq!(res[3], ("PAGEREF TargetMark \\p".into(), "above".into()));
|
||||
assert_eq!(res[4], ("REF TargetMark \\p".into(), "above".into()));
|
||||
}
|
||||
|
||||
/// A document with a body AUTHOR field and a header holding AUTHOR, NUMWORDS and a locked TITLE,
|
||||
/// all stale; the author is "Ada".
|
||||
fn doc_with_header_fields() -> (wordcraft_doc::Document, u32) {
|
||||
use wordcraft_doc::{CharProps, Paragraph, PartKind};
|
||||
let field = |instr: &str, locked: bool| InlineObject::Field {
|
||||
instr: instr.into(),
|
||||
result: "stale".into(),
|
||||
locked,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
let mut doc = wordcraft_doc::Document::from_text("one two three");
|
||||
doc.core.creator = "Ada".into();
|
||||
let mut p = Paragraph::new();
|
||||
for (instr, locked) in [("AUTHOR", false), ("NUMWORDS", false), ("TITLE", true)] {
|
||||
let off = p.len();
|
||||
p.insert_object(off, field(instr, locked), &CharProps::default()).unwrap();
|
||||
}
|
||||
let body_path = doc.para_paths(StoryRef::Body).remove(0);
|
||||
doc.para_mut(StoryRef::Body, &body_path).unwrap().insert_object(0, field("AUTHOR", false), &CharProps::default()).unwrap();
|
||||
let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]);
|
||||
(doc, hdr)
|
||||
}
|
||||
|
||||
fn story_fields(s: &Session, story: StoryRef) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for path in s.doc.para_paths(story) {
|
||||
for o in &s.doc.para(story, &path).unwrap().objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
out.push((instr.clone(), result.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone.
|
||||
#[test]
|
||||
fn update_fields_in_the_body_leaves_headers_alone() {
|
||||
let (doc, hdr) = doc_with_header_fields();
|
||||
let mut s = Session::new(doc);
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
assert_eq!(story_fields(&s, StoryRef::Body)[0], ("AUTHOR".to_string(), "Ada".to_string()));
|
||||
assert!(story_fields(&s, StoryRef::Part(hdr)).iter().all(|(_, r)| r == "stale"), "{:?}", story_fields(&s, StoryRef::Part(hdr)));
|
||||
}
|
||||
|
||||
/// #1706: with the caret in a header (or other part), Update Field refreshes that part's
|
||||
/// fields, but not locked ones and not the body's.
|
||||
#[test]
|
||||
fn update_fields_in_a_header_updates_that_header() {
|
||||
let (doc, hdr) = doc_with_header_fields();
|
||||
let mut s = Session::new(doc);
|
||||
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
|
||||
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got = story_fields(&s, StoryRef::Part(hdr));
|
||||
assert_eq!(got.len(), 3, "{got:?}");
|
||||
assert_eq!(got[0].1, "Ada");
|
||||
assert_eq!(got[1].1, s.doc.word_count().to_string());
|
||||
assert_eq!(got[2].1, "stale", "a locked field stays as it was");
|
||||
assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched");
|
||||
}
|
||||
|
||||
/// #1706: `all: true` (printing) updates the whole document even with the caret in a header.
|
||||
#[test]
|
||||
fn update_fields_all_covers_every_story() {
|
||||
let (doc, hdr) = doc_with_header_fields();
|
||||
let mut s = Session::new(doc);
|
||||
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
|
||||
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
|
||||
s.run("references.updateFields", &json!({"all": true})).unwrap();
|
||||
let got = story_fields(&s, StoryRef::Part(hdr));
|
||||
assert_eq!((got[0].1.as_str(), got[2].1.as_str()), ("Ada", "stale"), "{got:?}");
|
||||
assert_eq!(story_fields(&s, StoryRef::Body)[0].1, "Ada");
|
||||
// Junk values fall back to the caret's story.
|
||||
s.run("references.updateFields", &json!({"all": "yes"})).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("review.trackingOptions", "Track Changes Options", "Review › Tracking", tracking_options)
|
||||
.params(
|
||||
r#"{"comments"?: bool, "ink"?: bool, "insertionsDeletions"?: bool, "formatting"?: bool, "balloons"?: "revisions|inline|commentsAndFormatting", "commentDisplay"?: "margin|selected", "insertMark"?: "underline|doubleUnderline|bold|italic|strikethrough|colorOnly|none", "insertColor"?: "byAuthor" | "RRGGBB", "deleteMark"?: "strikethrough|doubleStrikethrough|hidden|caret|hash|underline|colorOnly", "deleteColor"?: "byAuthor" | "RRGGBB", "changedLines"?: "outside|left|right|none", "changedLinesColor"?: "auto" | "RRGGBB", "trackFormatting"?: bool} → the options (omit all to read them)"#,
|
||||
r#"{"comments"?: bool, "ink"?: bool, "insertionsDeletions"?: bool, "formatting"?: bool, "balloons"?: "revisions|inline|commentsAndFormatting", "commentDisplay"?: "margin|selected", "insertMark"?: "underline|doubleUnderline|bold|italic|strikethrough|colorOnly|none", "insertColor"?: "byAuthor" | "RRGGBB", "deleteMark"?: "strikethrough|doubleStrikethrough|hidden|caret|hash|underline|doubleUnderline|bold|italic|colorOnly|none", "deleteColor"?: "byAuthor" | "RRGGBB", "changedLines"?: "outside|left|right|none", "changedLinesColor"?: "auto" | "RRGGBB", "trackFormatting"?: bool, "moveFromMark"?: (as deleteMark; none = plain text, no colour), "moveFromColor"?: "byAuthor" | "RRGGBB", "moveToMark"?: (as insertMark; none = plain text, no colour), "moveToColor"?: "byAuthor" | "RRGGBB", "trackMoves"?: bool (the document's setting; off = a cut and paste is a deletion and an insertion, not a move)} → the options (omit all to read them)"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("review.wordCount", "Word Count", "Review › Proofing", word_count)
|
||||
@@ -982,18 +982,37 @@ fn tracking_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(x) = pick("deleteMark") {
|
||||
m.delete_mark = DeleteMark::parse(x).ok_or_else(|| CmdError::Params("unknown `deleteMark`".into()))?;
|
||||
}
|
||||
if let Some(x) = pick("moveFromMark") {
|
||||
m.move_from_mark = DeleteMark::parse(x).ok_or_else(|| CmdError::Params("unknown `moveFromMark`".into()))?;
|
||||
}
|
||||
if let Some(x) = pick("moveToMark") {
|
||||
m.move_to_mark = InsertMark::parse(x).ok_or_else(|| CmdError::Params("unknown `moveToMark`".into()))?;
|
||||
}
|
||||
if let Some(x) = pick("changedLines") {
|
||||
m.changed_lines = ChangeBar::parse(x).ok_or_else(|| CmdError::Params("unknown `changedLines`".into()))?;
|
||||
}
|
||||
m.insert_color = color("insertColor", "byAuthor", m.insert_color)?;
|
||||
m.delete_color = color("deleteColor", "byAuthor", m.delete_color)?;
|
||||
m.changed_lines_color = color("changedLinesColor", "auto", m.changed_lines_color)?;
|
||||
m.move_from_color = color("moveFromColor", "byAuthor", m.move_from_color)?;
|
||||
m.move_to_color = color("moveToColor", "byAuthor", m.move_to_color)?;
|
||||
// Track moves is the document's (`w:doNotTrackMoves`), so changing it is an undo step; the
|
||||
// command is pure, so it records the step itself, once every parameter has been accepted.
|
||||
if let Some(b) = p::bool(v, "trackMoves")
|
||||
&& b != s.doc.settings.track_moves
|
||||
{
|
||||
s.checkpoint("Track Changes Options");
|
||||
s.doc.settings.track_moves = b;
|
||||
s.touch();
|
||||
}
|
||||
if m != s.prefs.markup {
|
||||
s.prefs.markup = m;
|
||||
// Hidden deletions leave the layout.
|
||||
s.relayout();
|
||||
}
|
||||
Ok(tracking_json(&s.prefs.markup))
|
||||
let mut out = tracking_json(&s.prefs.markup);
|
||||
out["trackMoves"] = json!(s.doc.settings.track_moves);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Track Changes Options as `review.trackingOptions` takes them.
|
||||
@@ -1013,6 +1032,10 @@ pub fn tracking_json(m: &wordcraft_layout::display::MarkupOptions) -> Value {
|
||||
"changedLines": m.changed_lines.name(),
|
||||
"changedLinesColor": hex(m.changed_lines_color, "auto"),
|
||||
"trackFormatting": m.track_formatting,
|
||||
"moveFromMark": m.move_from_mark.name(),
|
||||
"moveFromColor": hex(m.move_from_color, "byAuthor"),
|
||||
"moveToMark": m.move_to_mark.name(),
|
||||
"moveToColor": hex(m.move_to_color, "byAuthor"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1094,6 +1117,238 @@ mod tests {
|
||||
assert_eq!(s.run("review.trackingOptions", &json!({"commentDisplay": "margin"})).unwrap()["commentDisplay"], "margin");
|
||||
}
|
||||
|
||||
/// Gap #5 (Track Changes Options › Moves): moved text is drawn with the Moved from / Moved
|
||||
/// to marks and colours, by default green with a double strikethrough where it was and a
|
||||
/// double underline where it went.
|
||||
#[test]
|
||||
fn move_marks_and_colours_change_how_moved_text_is_drawn() {
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_layout::display::{MOVE_COLOR, Stroke};
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap();
|
||||
s.run("edit.cut", &json!({})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap();
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
// Horizontal lines (underlines, strikethroughs) with their colour and stroke.
|
||||
let strokes = |d: &[Draw]| -> Vec<(Rgb, Stroke)> {
|
||||
d.iter()
|
||||
.filter_map(|x| if let Draw::Line { y0, y1, color, stroke, .. } = x { (y0 == y1).then_some((*color, *stroke)) } else { None })
|
||||
.collect()
|
||||
};
|
||||
|
||||
let r = s.run("review.trackingOptions", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(r["moveFromMark"].as_str(), r["moveFromColor"].as_str(), r["moveToMark"].as_str(), r["moveToColor"].as_str()),
|
||||
(Some("doubleStrikethrough"), Some(MOVE_COLOR.hex().as_str()), Some("doubleUnderline"), Some(MOVE_COLOR.hex().as_str())),
|
||||
);
|
||||
let (d, _) = draws(&mut s);
|
||||
let st = strokes(&d);
|
||||
assert_eq!(
|
||||
st.iter().filter(|(c, k)| *c == MOVE_COLOR && *k == Stroke::Double).count(),
|
||||
2,
|
||||
"green double underline and strikethrough: {st:?}"
|
||||
);
|
||||
assert!(glyph_colors(&d).contains(&(MOVE_COLOR, false)));
|
||||
|
||||
// Red single strikethrough where it was; bold blue where it went.
|
||||
let r = s
|
||||
.run(
|
||||
"review.trackingOptions",
|
||||
&json!({"moveFromMark": "strikethrough", "moveFromColor": "FF0000", "moveToMark": "bold", "moveToColor": "0000FF"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((r["moveFromMark"].as_str(), r["moveToColor"].as_str()), (Some("strikethrough"), Some("0000FF")));
|
||||
let (d, _) = draws(&mut s);
|
||||
let st = strokes(&d);
|
||||
assert_eq!(st, vec![(Rgb(0xFF, 0, 0), Stroke::Solid)], "only a red strikethrough is left");
|
||||
let g = glyph_colors(&d);
|
||||
assert!(g.contains(&(Rgb(0, 0, 0xFF), true)) && g.contains(&(Rgb(0xFF, 0, 0), false)), "{g:?}");
|
||||
assert!(!g.iter().any(|(c, _)| *c == MOVE_COLOR), "{g:?}");
|
||||
|
||||
// By author: the insertion and deletion colour of the author.
|
||||
s.run("review.trackingOptions", &json!({"moveToColor": "byAuthor"})).unwrap();
|
||||
let (d, _) = draws(&mut s);
|
||||
let author = wordcraft_layout::display::revision_color(0);
|
||||
assert!(glyph_colors(&d).contains(&(author, true)), "{:?}", glyph_colors(&d));
|
||||
|
||||
// Unknown choices are refused.
|
||||
assert!(s.run("review.trackingOptions", &json!({"moveToMark": "sparkles"})).is_err());
|
||||
assert!(s.run("review.trackingOptions", &json!({"moveFromColor": "green"})).is_err());
|
||||
}
|
||||
|
||||
/// Three paragraphs; with Track Changes on, the second cut and pasted before the first, then
|
||||
/// the Track Changes Options `opts` applied.
|
||||
fn moved_with(opts: serde_json::Value) -> Session {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap();
|
||||
s.run("edit.cut", &json!({})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap();
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
s.run("review.trackingOptions", &opts).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The caret at body paragraph `block`, offset `off`: (x, top).
|
||||
fn caret_at(s: &mut Session, block: usize, off: usize) -> (f32, f32) {
|
||||
let c = s.layout().caret(&wordcraft_doc::Pos::body(block, off)).unwrap();
|
||||
(c.x, c.top)
|
||||
}
|
||||
|
||||
/// Review of gap #5: Moved from = Hidden takes the moved-away paragraph out of the layout,
|
||||
/// as Hidden does for deletions: the text below closes up as if the move were accepted, the
|
||||
/// caret can't land inside it, and nothing of it is drawn, not even its ¶.
|
||||
#[test]
|
||||
fn hidden_moved_from_text_takes_no_room() {
|
||||
let mut accepted = moved_with(json!({}));
|
||||
accepted.run("review.acceptAll", &json!({})).unwrap();
|
||||
let third_accepted = caret_at(&mut accepted, 2, 0).1;
|
||||
|
||||
let mut s = moved_with(json!({"moveFromMark": "hidden"}));
|
||||
assert_eq!(s.doc.body.len(), 4, "the moved-away paragraph is still in the document");
|
||||
assert!((caret_at(&mut s, 3, 0).1 - third_accepted).abs() < 0.01, "no blank line where it was");
|
||||
// Inside it is the same place as its start and as the paragraph after it.
|
||||
assert_eq!(caret_at(&mut s, 2, 5), caret_at(&mut s, 2, 0));
|
||||
assert_eq!(caret_at(&mut s, 2, 0), caret_at(&mut s, 3, 0));
|
||||
let (d, _) = draws(&mut s);
|
||||
assert_eq!(glyph_text(&d).matches("Second").count(), 1, "{:?}", glyph_text(&d));
|
||||
// Formatting marks shown: one ¶ per visible paragraph.
|
||||
let l = s.layout();
|
||||
let opts = DisplayOptions { marks: true, revisions: s.prefs.markup.clone(), ..Default::default() };
|
||||
let d = page_display(&s.doc, &l.pages[0], &opts);
|
||||
assert_eq!(d.iter().filter(|x| matches!(x, Draw::Mark { ch: '¶', .. })).count(), 3);
|
||||
}
|
||||
|
||||
/// Each "Second" run drawn, in page order (the moved-to paragraph first, then the moved-from
|
||||
/// one): (colour, bold, italic).
|
||||
fn seconds(d: &[Draw]) -> Vec<(wordcraft_doc::Rgb, bool, bool)> {
|
||||
d.iter()
|
||||
.filter_map(|x| match x {
|
||||
Draw::Glyphs { text, color, synth_bold, synth_italic, .. } if text.starts_with("Second") => {
|
||||
Some((*color, *synth_bold, *synth_italic))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Horizontal lines (underlines and strikethroughs): their strokes.
|
||||
fn h_strokes(d: &[Draw]) -> Vec<wordcraft_layout::display::Stroke> {
|
||||
d.iter().filter_map(|x| if let Draw::Line { y0, y1, stroke, .. } = x { (y0 == y1).then_some(*stroke) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// Change bars: vertical lines left of the body.
|
||||
fn bars(d: &[Draw], body_x: f32) -> usize {
|
||||
lines(d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count()
|
||||
}
|
||||
|
||||
/// Gap #5, Word for Mac 16.113.4 (2026-10-15): Moved to = (none) draws the moved text as
|
||||
/// plain text, no mark and no colour, although the colour stays set; the change bar stays.
|
||||
#[test]
|
||||
fn moved_to_none_is_plain_text_with_its_change_bar() {
|
||||
use wordcraft_layout::display::{MOVE_COLOR, Stroke};
|
||||
let mut s = moved_with(json!({}));
|
||||
let (d, body_x) = draws(&mut s);
|
||||
let default_bars = bars(&d, body_x);
|
||||
assert!(default_bars > 0);
|
||||
let mut s = moved_with(json!({"moveToMark": "none"}));
|
||||
assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["moveToColor"], MOVE_COLOR.hex());
|
||||
let (d, body_x) = draws(&mut s);
|
||||
let sec = seconds(&d);
|
||||
assert_eq!(sec.first().map(|c| c.0), Some(wordcraft_doc::Rgb::BLACK), "{sec:?}");
|
||||
assert_eq!(sec.get(1).map(|c| c.0), Some(MOVE_COLOR), "the moved-from end keeps its colour");
|
||||
assert_eq!(h_strokes(&d), vec![Stroke::Double], "only the moved-from strikethrough");
|
||||
assert_eq!(bars(&d, body_x), default_bars, "the change bars stay");
|
||||
}
|
||||
|
||||
/// Gap #5, Word for Mac 16.113.4: Deletions and Moved from offer (none), Bold, Italic and
|
||||
/// Double underline too. Moved from (none) is plain text, as Moved to (none); an ordinary
|
||||
/// deletion marked (none) keeps its colour.
|
||||
#[test]
|
||||
fn deletion_marks_none_bold_italic_and_double_underline() {
|
||||
use wordcraft_layout::display::{MOVE_COLOR, Stroke};
|
||||
let plain = json!({"moveToMark": "none", "moveToColor": "byAuthor"});
|
||||
let with = |extra: serde_json::Value| {
|
||||
let mut o = plain.clone();
|
||||
o.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
moved_with(o)
|
||||
};
|
||||
let mut s = with(json!({"moveFromMark": "none"}));
|
||||
let (d, _) = draws(&mut s);
|
||||
assert_eq!(seconds(&d).get(1).map(|c| c.0), Some(wordcraft_doc::Rgb::BLACK), "{:?}", seconds(&d));
|
||||
assert!(h_strokes(&d).is_empty(), "{:?}", h_strokes(&d));
|
||||
let mut s = with(json!({"moveFromMark": "bold"}));
|
||||
let (d, _) = draws(&mut s);
|
||||
assert_eq!(seconds(&d).get(1), Some(&(MOVE_COLOR, true, false)));
|
||||
assert!(h_strokes(&d).is_empty());
|
||||
let mut s = with(json!({"moveFromMark": "italic"}));
|
||||
let (d, _) = draws(&mut s);
|
||||
assert_eq!(seconds(&d).get(1), Some(&(MOVE_COLOR, false, true)));
|
||||
let mut s = with(json!({"moveFromMark": "doubleUnderline"}));
|
||||
let (d, _) = draws(&mut s);
|
||||
assert_eq!(h_strokes(&d), vec![Stroke::Double]);
|
||||
let r = s.run("review.trackingOptions", &json!({"deleteMark": "none"})).unwrap();
|
||||
assert_eq!((r["deleteMark"].as_str(), r["moveFromMark"].as_str()), (Some("none"), Some("doubleUnderline")));
|
||||
|
||||
// An ordinary deletion marked (none): no line, the author's colour.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "keep gone"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 9}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
s.run("review.trackingOptions", &json!({"deleteMark": "none"})).unwrap();
|
||||
let (d, _) = draws(&mut s);
|
||||
assert!(h_strokes(&d).is_empty());
|
||||
let gone = d.iter().find_map(|x| if let Draw::Glyphs { text, color, .. } = x { text.contains("gone").then_some(*color) } else { None });
|
||||
assert_eq!(gone, Some(wordcraft_layout::display::revision_color(0)));
|
||||
}
|
||||
|
||||
/// Review of gap #5: Deletions = Hidden hides deletions, not moved-away text, which follows
|
||||
/// Moved from (left at its default: shown, green, double strikethrough) and keeps its room,
|
||||
/// as in Word for Mac 16.113.4 (user-checked 2026-10-15).
|
||||
#[test]
|
||||
fn hidden_deletions_leave_moved_from_text_shown() {
|
||||
use wordcraft_layout::display::{MOVE_COLOR, Stroke};
|
||||
let mut accepted = moved_with(json!({}));
|
||||
accepted.run("review.acceptAll", &json!({})).unwrap();
|
||||
let third_accepted = caret_at(&mut accepted, 2, 0).1;
|
||||
let mut s = moved_with(json!({"deleteMark": "hidden"}));
|
||||
assert!(caret_at(&mut s, 3, 0).1 > third_accepted + 1.0, "the moved-away paragraph keeps its line");
|
||||
let (d, _) = draws(&mut s);
|
||||
assert_eq!(glyph_text(&d).matches("Second").count(), 2, "{:?}", glyph_text(&d));
|
||||
let struck = d.iter().any(|x| matches!(x, Draw::Line { color, stroke: Stroke::Double, y0, y1, .. } if *color == MOVE_COLOR && y0 == y1));
|
||||
assert!(struck, "green double lines");
|
||||
}
|
||||
|
||||
/// Gap #5: Track moves is a document setting (`w:doNotTrackMoves`): turning it off is one
|
||||
/// undo step, and setting it to what it is adds none.
|
||||
#[test]
|
||||
fn track_moves_is_a_document_setting_with_one_undo_step() {
|
||||
let mut s = Session::new(Document::new());
|
||||
assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["trackMoves"], true);
|
||||
assert!(s.doc.settings.track_moves);
|
||||
assert!(!s.can_undo());
|
||||
assert_eq!(s.run("review.trackingOptions", &json!({"trackMoves": true})).unwrap()["trackMoves"], true);
|
||||
assert!(!s.can_undo(), "no change, no undo step");
|
||||
assert_eq!(s.run("review.trackingOptions", &json!({"trackMoves": false})).unwrap()["trackMoves"], false);
|
||||
assert!(!s.doc.settings.track_moves && s.dirty);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.settings.track_moves);
|
||||
assert!(!s.can_undo());
|
||||
// A refused parameter changes nothing.
|
||||
assert!(s.run("review.trackingOptions", &json!({"trackMoves": false, "moveToMark": "sparkles"})).is_err());
|
||||
assert!(s.doc.settings.track_moves);
|
||||
// Documents saved before the setting existed keep tracking moves.
|
||||
let old: wordcraft_doc::Settings = serde_json::from_value(json!({"trackChanges": true})).unwrap();
|
||||
assert!(old.track_moves);
|
||||
}
|
||||
|
||||
/// #1055: the caret's own comment is the one whose range holds it, not any in its paragraph.
|
||||
#[test]
|
||||
fn comment_range_at_caret_needs_the_caret_inside_the_range() {
|
||||
|
||||
@@ -586,6 +586,35 @@ fn del_col(s: &mut Session, _: &Value) -> CmdResult {
|
||||
fix_caret(s, &tp, r, c.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Backspace with whole cells selected (#1844): whole rows go like Delete Rows (every row: the
|
||||
/// table, like Delete Table), whole columns like Delete Columns, with their undo and Track
|
||||
/// Changes. `None` for any other block of cells, and for a single cell of a table with more
|
||||
/// (Backspace then clears them).
|
||||
pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
let cr = s.cell_range()?.clone();
|
||||
let (tp, _, _) = s.sel.focus.path.cell()?;
|
||||
if cr.story != s.sel.focus.story || cr.table != tp {
|
||||
return None;
|
||||
}
|
||||
let t = s.doc.table(cr.story, &tp)?;
|
||||
// One cell of several is just that cell, also in a one-row or one-column table (Word's cell
|
||||
// selection doesn't take the end-of-row mark): Backspace clears it.
|
||||
let cells: usize = t.rows.iter().map(|row| row.cells.len()).sum();
|
||||
if cells > 1 && cr.cells(t).len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let r = if cr.cols == (0, last_col) {
|
||||
del_row(s, &Value::Null)
|
||||
} else if cr.rows == (0, last_row) {
|
||||
del_col(s, &Value::Null)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
s.column = None;
|
||||
Some(r)
|
||||
}
|
||||
|
||||
fn del_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
|
||||
@@ -109,10 +109,10 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// A list label ("a. ") becomes a list before AutoCorrect could capitalise it.
|
||||
let listed = text == " " && s.prefs.autocorrect.enabled && list_autoformat(s)?;
|
||||
if !listed {
|
||||
super::tools::autocorrect(s)?;
|
||||
super::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?;
|
||||
}
|
||||
if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes {
|
||||
dash_autoformat(s)?;
|
||||
dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
|
||||
}
|
||||
if let Some((para, sel)) = typed
|
||||
&& s.doc.para_at(&f) != Some(¶)
|
||||
@@ -122,8 +122,14 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
*p = para;
|
||||
p.touch();
|
||||
s.push_undo("AutoFormat", doc, sel);
|
||||
// Undo right after it makes the capitalised word an exception.
|
||||
let depth = s.undo_steps_so_far();
|
||||
if let Some(c) = s.autocap.as_mut().filter(|c| c.depth == u64::MAX) {
|
||||
c.depth = depth;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.autocap.take_if(|c| c.depth == u64::MAX);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -165,7 +171,10 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let len = para.len();
|
||||
para.delete(0, len)?;
|
||||
para.props.numbering = Some(NumRef { num, level: 0 });
|
||||
para.props.style = Some("ListParagraph".into());
|
||||
// A heading stays a heading (Word: "1. " in a Heading 1 gives a numbered Heading 1).
|
||||
if para.props.style.as_deref().is_none_or(|st| st == "Normal") {
|
||||
para.props.style = Some("ListParagraph".into());
|
||||
}
|
||||
s.sel = Selection::caret(Pos { off: 0, ..f });
|
||||
Ok(true)
|
||||
}
|
||||
@@ -587,6 +596,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
|
||||
if s.sel.is_collapsed() && at.off > 0 {
|
||||
super::tools::autocorrect_word(s, true)?;
|
||||
s.autocap.take_if(|c| c.depth == u64::MAX);
|
||||
at = s.sel.focus.clone();
|
||||
}
|
||||
let new = split_para(s, &at)?;
|
||||
@@ -678,6 +688,9 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
if f.off > 0 {
|
||||
let a = Pos { off: para.backspace_from(f.off), ..f.clone() };
|
||||
if let Some(c) = s.autocap.as_mut() {
|
||||
c.note_backspace(&a, &f);
|
||||
}
|
||||
s.sel = Selection { anchor: a, focus: f };
|
||||
delete_selection(s)?;
|
||||
return sel_result(s);
|
||||
|
||||
+173
-27
@@ -48,6 +48,7 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[
|
||||
("(tm)", "™"),
|
||||
("...", "…"),
|
||||
("->", "→"),
|
||||
("-->", "→"),
|
||||
("<-", "←"),
|
||||
("=>", "⇒"),
|
||||
(":)", "☺"),
|
||||
@@ -112,6 +113,11 @@ pub struct AutoCorrectPrefs {
|
||||
pub exceptions_removed: Vec<String>,
|
||||
/// The user's words that keep two initial capitals (besides the built-in ones).
|
||||
pub initial_caps_exceptions: Vec<String>,
|
||||
/// Undoing (or backspacing and retyping) an automatic sentence capital adds the word before
|
||||
/// it to the exceptions (First Letter's "Automatically add words to list").
|
||||
pub auto_add_first_letter: bool,
|
||||
/// The same for undoing a TWo INitial CApitals fix (INitial CAps's switch).
|
||||
pub auto_add_initial_caps: bool,
|
||||
}
|
||||
|
||||
impl Default for AutoCorrectPrefs {
|
||||
@@ -137,6 +143,8 @@ impl Default for AutoCorrectPrefs {
|
||||
exceptions: Vec::new(),
|
||||
exceptions_removed: Vec::new(),
|
||||
initial_caps_exceptions: Vec::new(),
|
||||
auto_add_first_letter: true,
|
||||
auto_add_initial_caps: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,6 +220,30 @@ impl AutoCorrectPrefs {
|
||||
v
|
||||
}
|
||||
|
||||
/// Add a First Letter exception (lowercase; one the built-in list has just stops being deleted).
|
||||
fn add_exception(&mut self, e: String) {
|
||||
if !plain_word(&e) {
|
||||
return;
|
||||
}
|
||||
self.exceptions_removed.retain(|r| *r != e);
|
||||
if !NOT_SENTENCE_END.contains(&e.as_str()) && !self.exceptions.contains(&e) && self.exceptions.len() < MAX_ENTRIES {
|
||||
self.exceptions.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a word that keeps its two initial capitals.
|
||||
fn add_initial_caps_exception(&mut self, w: &str) {
|
||||
if !plain_word(w) {
|
||||
return;
|
||||
}
|
||||
if !INITIAL_CAPS_EXCEPTIONS.contains(&w)
|
||||
&& !self.initial_caps_exceptions.iter().any(|e| e == w)
|
||||
&& self.initial_caps_exceptions.len() < MAX_ENTRIES
|
||||
{
|
||||
self.initial_caps_exceptions.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Abbreviations after which a sentence doesn't end, lowercase.
|
||||
pub fn exception_list(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = NOT_SENTENCE_END
|
||||
@@ -226,6 +258,50 @@ impl AutoCorrectPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `w` a word an exception list may hold: one short line, no spaces or object characters?
|
||||
fn plain_word(w: &str) -> bool {
|
||||
!w.trim().is_empty() && w.chars().count() <= MAX_FROM && !w.contains(|c: char| c.is_whitespace() || c == '\u{FFFC}')
|
||||
}
|
||||
|
||||
/// The newest automatic capitalisation (a sentence capital, a TWo INitial CApitals fix), kept so
|
||||
/// that undoing it, or backspacing it and typing the word again, makes an exception (#1857).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutoCap {
|
||||
/// Undo steps pushed (ever) right after the change (`u64::MAX` until the caller has pushed its step).
|
||||
pub(crate) depth: u64,
|
||||
story: wordcraft_doc::StoryRef,
|
||||
path: wordcraft_doc::Path,
|
||||
/// Byte offset of the word in its paragraph.
|
||||
start: usize,
|
||||
/// The token before a sentence capital's word, lowercase ("there."); empty when none or when
|
||||
/// the capital wasn't a sentence one.
|
||||
prev: String,
|
||||
/// The TWo INitial CApitals word as typed ("TWo").
|
||||
two_caps: Option<String>,
|
||||
/// The user backspaced into the word after the change.
|
||||
backspaced: bool,
|
||||
}
|
||||
|
||||
impl AutoCap {
|
||||
pub(crate) fn add_exceptions(self, ac: &mut AutoCorrectPrefs) {
|
||||
if ac.auto_add_first_letter && !self.prev.is_empty() {
|
||||
ac.add_exception(self.prev);
|
||||
}
|
||||
if ac.auto_add_initial_caps
|
||||
&& let Some(w) = self.two_caps
|
||||
{
|
||||
ac.add_initial_caps_exception(&w);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Backspace deleted `a..f`; it reached back over the capitalised word's first letter.
|
||||
pub(crate) fn note_backspace(&mut self, a: &Pos, f: &Pos) {
|
||||
if f.story == self.story && f.path == self.path && a.off <= self.start && f.off > self.start {
|
||||
self.backspaced = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved AutoCorrect options: anything that doesn't read as them reads as the defaults (a
|
||||
/// damaged `ui.json` never loses the other preferences).
|
||||
pub fn lenient_autocorrect<'de, D: serde::Deserializer<'de>>(d: D) -> Result<AutoCorrectPrefs, D::Error> {
|
||||
@@ -252,6 +328,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
("bullets", &mut ac.bullets),
|
||||
("numbering", &mut ac.numbering),
|
||||
("borderLines", &mut ac.border_lines),
|
||||
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
|
||||
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
|
||||
] {
|
||||
if let Some(b) = p::bool(v, k) {
|
||||
*f = b;
|
||||
@@ -286,10 +364,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if bad(&e, MAX_FROM) || e.contains(char::is_whitespace) {
|
||||
return Err(CmdError::Params("an exception is one abbreviation, like \"approx.\"".into()));
|
||||
}
|
||||
ac.exceptions_removed.retain(|r| *r != e);
|
||||
if !NOT_SENTENCE_END.contains(&e.as_str()) && !ac.exceptions.contains(&e) && ac.exceptions.len() < MAX_ENTRIES {
|
||||
ac.exceptions.push(e);
|
||||
}
|
||||
ac.add_exception(e);
|
||||
}
|
||||
if let Some(e) = p::str(v, "deleteException") {
|
||||
let e = e.trim().to_lowercase();
|
||||
@@ -303,12 +378,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if bad(w, MAX_FROM) || w.contains(char::is_whitespace) {
|
||||
return Err(CmdError::Params("an INitial CAps exception is one word, like \"IDs\"".into()));
|
||||
}
|
||||
if !INITIAL_CAPS_EXCEPTIONS.contains(&w)
|
||||
&& !ac.initial_caps_exceptions.iter().any(|e| e == w)
|
||||
&& ac.initial_caps_exceptions.len() < MAX_ENTRIES
|
||||
{
|
||||
ac.initial_caps_exceptions.push(w.to_string());
|
||||
}
|
||||
ac.add_initial_caps_exception(w);
|
||||
}
|
||||
if let Some(w) = p::str(v, "deleteInitialCapsException") {
|
||||
let w = w.trim();
|
||||
@@ -331,6 +401,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"bullets": ac.bullets,
|
||||
"numbering": ac.numbering,
|
||||
"borderLines": ac.border_lines,
|
||||
"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<_>>(),
|
||||
"user": ac.entries,
|
||||
"exceptions": ac.exception_list(),
|
||||
@@ -366,7 +438,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, "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, "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)
|
||||
@@ -779,8 +851,17 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
if word.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// The word's language (run, paragraph style, document default) picks the replacement list
|
||||
// (#835) and the case mapping (#1923). Text with no language at all is in the editing
|
||||
// language (a Polish interface writes Polish), else English.
|
||||
let lang: Option<String> = s.doc.para_at(&f).and_then(|p| {
|
||||
let rc = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(start));
|
||||
rc.lang.clone()
|
||||
});
|
||||
let lang = lang.or_else(|| s.editing_lang.clone());
|
||||
let turkic = is_turkic(lang.as_deref());
|
||||
// TWo INitial CApitals and cAPS LOCK come first, so "TEh" is still a replacement's typo.
|
||||
if let Some((at, reps)) = case_fix(&ac, &word) {
|
||||
if let Some((at, reps)) = case_fix(&ac, &word, turkic) {
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
let letters = word.get(at..).unwrap_or("").chars().take(reps.len()).collect::<String>();
|
||||
let mut end = start + at + letters.len();
|
||||
@@ -793,20 +874,26 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
}
|
||||
end = from;
|
||||
}
|
||||
s.autocap = Some(AutoCap {
|
||||
depth: u64::MAX,
|
||||
story: f.story,
|
||||
path: f.path.clone(),
|
||||
start,
|
||||
prev: String::new(),
|
||||
two_caps: (ac.two_initial_caps
|
||||
&& letters.chars().nth(1).is_some_and(char::is_uppercase)
|
||||
&& letters.chars().next().is_some_and(char::is_uppercase))
|
||||
.then(|| letters.clone()),
|
||||
backspaced: false,
|
||||
});
|
||||
let fixed: String = reps.concat();
|
||||
f.off = (f.off + fixed.len()).saturating_sub(letters.len());
|
||||
s.sel = Selection::caret(f.clone());
|
||||
word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or(""));
|
||||
}
|
||||
// A whole-word replacement, else "wait..." → "wait…".
|
||||
// The word's language (run, paragraph style, document default) picks the list (#835).
|
||||
// Text with no language at all is in the editing language (a Polish interface writes Polish),
|
||||
// else English. Proofing decides what is English the same way.
|
||||
let english = s.doc.para_at(&f).is_none_or(|p| {
|
||||
let rc = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(start));
|
||||
let lang = rc.lang.as_deref().or(s.editing_lang.as_deref());
|
||||
wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::En
|
||||
});
|
||||
// A whole-word replacement, else "wait..." → "wait…". Proofing decides what is English the
|
||||
// same way (#835).
|
||||
let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()) == wordcraft_proof::ProofLang::En);
|
||||
let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None };
|
||||
let (from, rep) = match found {
|
||||
Some(r) => (start, Some(r)),
|
||||
@@ -818,6 +905,7 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
},
|
||||
None => (start, None),
|
||||
};
|
||||
let replaced = rep.is_some();
|
||||
let mut caret = f.off;
|
||||
if let Some(rep) = rep {
|
||||
let end = start + word.len();
|
||||
@@ -866,22 +954,80 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
} else {
|
||||
last
|
||||
};
|
||||
let sentence = sentence_on && starts_sentence(before_word, &exceptions) && should_capitalize(first, next);
|
||||
let mut sentence = sentence_on && starts_sentence(before_word, &exceptions) && should_capitalize(first, next);
|
||||
let prev = if sentence { prev_token(before_word) } else { String::new() };
|
||||
// Typing the word again after backspacing an automatic capital: the user doesn't want it
|
||||
// (Word's "Automatically add words to list"); an exception, so no capital this time either.
|
||||
if sentence
|
||||
&& !prev.is_empty()
|
||||
&& s.autocap.take_if(|c| c.backspaced && c.story == f.story && c.path == f.path && c.start == start && c.prev == prev).is_some()
|
||||
&& ac.auto_add_first_letter
|
||||
{
|
||||
s.prefs.autocorrect.add_exception(prev.clone());
|
||||
sentence = false;
|
||||
}
|
||||
let day = ac.cap_days && DAYS.contains(&first);
|
||||
if sentence || day {
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
if let Some(c) = word.chars().next() {
|
||||
let up: String = c.to_uppercase().collect();
|
||||
let up = upper_char(c, turkic);
|
||||
let props = para.props_of_char(start).clone();
|
||||
para.delete(start, start + c.len_utf8())?;
|
||||
para.insert_text(start, &up, &props)?;
|
||||
let off = (caret + up.len()).saturating_sub(c.len_utf8());
|
||||
s.sel = Selection::caret(Pos { off, ..f });
|
||||
s.sel = Selection::caret(Pos { off, ..f.clone() });
|
||||
if sentence && !prev.is_empty() && !replaced {
|
||||
match s.autocap.as_mut().filter(|c| c.depth == u64::MAX) {
|
||||
Some(c) => c.prev = prev,
|
||||
None => {
|
||||
s.autocap = Some(AutoCap { depth: u64::MAX, story: f.story, path: f.path, start, prev, two_caps: None, backspaced: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The lowercase token before a word that starts a sentence ("there." in "there. world"), as
|
||||
/// [`starts_sentence`] reads it; empty at the start of a paragraph.
|
||||
fn prev_token(before: &str) -> String {
|
||||
let t = before.trim_end().trim_end_matches(|c: char| "\"'”’)]".contains(c));
|
||||
// Only full stops: Word's exception was observed with "there." (`there?` isn't added).
|
||||
if !t.ends_with('.') {
|
||||
return String::new();
|
||||
}
|
||||
t.rsplit(char::is_whitespace).next().unwrap_or("").trim_start_matches(|c: char| "\"'“‘([".contains(c)).to_lowercase()
|
||||
}
|
||||
|
||||
/// Turkish, Azerbaijani (and Crimean Tatar) map `i`/`İ` and `ı`/`I` as pairs (#1923).
|
||||
fn is_turkic(lang: Option<&str>) -> bool {
|
||||
if wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::Tr {
|
||||
return true; // tr, tur, any region, same normalisation as proofing
|
||||
}
|
||||
let Some(tag) = lang.map(str::trim) else { return false };
|
||||
let primary = tag.split(['-', '_']).next().unwrap_or("");
|
||||
["az", "aze", "crh"].iter().any(|c| primary.eq_ignore_ascii_case(c))
|
||||
}
|
||||
|
||||
/// A letter in capitals: `i` → `İ` in Turkic languages, otherwise the Unicode default.
|
||||
fn upper_char(c: char, turkic: bool) -> String {
|
||||
if turkic && c == 'i' {
|
||||
return "\u{130}".to_string();
|
||||
}
|
||||
c.to_uppercase().collect()
|
||||
}
|
||||
|
||||
/// A letter in lowercase: `I` → `ı` in Turkic languages. `İ` is always the single letter `i`,
|
||||
/// never `i` plus a combining dot.
|
||||
fn lower_char(c: char, turkic: bool) -> String {
|
||||
match c {
|
||||
'I' if turkic => "\u{131}".to_string(),
|
||||
'\u{130}' => "i".to_string(),
|
||||
_ => c.to_lowercase().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in words that keep their two initial capitals (plurals of two-letter abbreviations).
|
||||
pub const INITIAL_CAPS_EXCEPTIONS: &[&str] = &["CDs", "IDs", "PCs", "TVs", "DJs", "GBs", "KBs", "MBs", "TBs", "VMs"];
|
||||
|
||||
@@ -891,7 +1037,7 @@ const CAPS_LOCK_EXCEPTIONS: &[&str] = &["iOS", "mRNA", "tRNA", "rRNA", "cDNA", "
|
||||
/// The capitalisation fix for a finished word: TWo INitial CApitals ("WOrd" → "Word") or cAPS
|
||||
/// LOCK ("wORD" → "Word"). Returns the byte offset of the word's letters and each letter's
|
||||
/// replacement. Only words of three or more letters, optionally in quotes or brackets.
|
||||
fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec<String>)> {
|
||||
fn case_fix(caps: &AutoCorrectPrefs, word: &str, turkic: bool) -> Option<(usize, Vec<String>)> {
|
||||
if !caps.two_initial_caps && !caps.caps_lock {
|
||||
return None;
|
||||
}
|
||||
@@ -908,7 +1054,7 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec<String>)>
|
||||
_ => return None,
|
||||
};
|
||||
let one = |c: char| c.to_string();
|
||||
let lower = |c: char| c.to_lowercase().collect::<String>();
|
||||
let lower = |c: char| lower_char(c, turkic);
|
||||
if caps.two_initial_caps
|
||||
&& a.is_uppercase()
|
||||
&& b.is_uppercase()
|
||||
@@ -919,7 +1065,7 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec<String>)>
|
||||
return Some((at, std::iter::once(one(a)).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| one(c))).collect()));
|
||||
}
|
||||
if caps.caps_lock && a.is_lowercase() && b.is_uppercase() && more.iter().all(|c| c.is_uppercase()) && !CAPS_LOCK_EXCEPTIONS.contains(&letters) {
|
||||
let up = a.to_uppercase().collect::<String>();
|
||||
let up = upper_char(a, turkic);
|
||||
return Some((at, std::iter::once(up).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| lower(c))).collect()));
|
||||
}
|
||||
None
|
||||
|
||||
@@ -70,6 +70,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(),
|
||||
CommandSpec::new("view.pageWidth", "Page Width", "View › Zoom", |s, _| zoom(s, &json!({"value": "pageWidth"}))).pure(),
|
||||
CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(),
|
||||
// The interface theme itself (light, dark, or follow the OS), next to dark pages (#1861).
|
||||
// The session can't paint: like split and new windows, this queues a request the front
|
||||
// end applies to its theme setting (which persists per user). Without a value it toggles
|
||||
// light and dark, as the manual switch did.
|
||||
CommandSpec::new("view.interfaceTheme", "Interface Theme", "View › Dark Mode", |s, v| match v.get("value") {
|
||||
None => {
|
||||
s.ui_requests.push(json!({"toggle": "interfaceTheme"}));
|
||||
Ok(json!({"value": "toggle"}))
|
||||
}
|
||||
Some(Value::String(name)) => match name.as_str() {
|
||||
"light" | "dark" | "system" => {
|
||||
s.ui_requests.push(json!({"interfaceTheme": name}));
|
||||
Ok(json!({"value": name}))
|
||||
}
|
||||
_ => Err(CmdError::Params("interface theme: light|dark|system".into())),
|
||||
},
|
||||
_ => Err(CmdError::Params("interface theme: light|dark|system".into())),
|
||||
})
|
||||
.params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(),
|
||||
CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(),
|
||||
CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| {
|
||||
|
||||
@@ -15,7 +15,7 @@ use wordcraft_doc::{Paragraph, PartKind, Pos, StoryRef, para_block};
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
const EFFECT_PARAMS: &str = r#"{"style"?: "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset", "clear"?: bool, "fill"?: "RRGGBB"|"none" (hollow)|{"color": "RRGGBB", "transparency"?: %}|{"gradient": ["RRGGBB", …], "angle"?: deg}|null (the run's colour), "outline"?: "RRGGBB"|"none"|{"color"?: "RRGGBB"|null, "width"?: pt, "transparency"?: %}|null, "shadow"?: preset|{"color"?, "transparency"?, "blur"?, "distance"?, "angle"?}|null, "glow"?: pt|{"color"?, "size"?, "transparency"?}|null, "reflection"?: bool|{"transparency"?: %, "size"?: %, "distance"?: pt}|null} (an omitted key is left as it is; with a shape selected or the caret in a text box, the box's whole text)"#;
|
||||
const EFFECT_PARAMS: &str = r#"{"style"?: "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset", "clear"?: bool, "fill"?: "RRGGBB"|"none" (hollow)|{"color": "RRGGBB", "transparency"?: %}|{"gradient": ["RRGGBB", …], "angle"?: deg}|null (the run's colour), "outline"?: "RRGGBB"|"none"|{"color"?: "RRGGBB"|null, "width"?: pt, "transparency"?: %}|null, "shadow"?: preset|{"color"?, "transparency"?, "blur"?, "distance"?, "angle"?}|null, "glow"?: pt|{"color"?, "size"?, "transparency"?}|null, "reflection"?: bool|"none"|{"transparency"?: %, "size"?: %, "distance"?: pt}|null} (an omitted key is left as it is; with a shape selected or the caret in a text box, the box's whole text)"#;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -100,13 +100,14 @@ fn parse_outline(v: Option<&Value>) -> Result<Change<TextOutline>, CmdError> {
|
||||
Ok(match v {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(x)) if x == "none" => Some(Some(TextOutline { color: None, ..d })),
|
||||
Some(Value::String(x)) if x.eq_ignore_ascii_case("none") => Some(Some(TextOutline { color: None, ..d })),
|
||||
Some(Value::String(x)) => {
|
||||
Some(Some(TextOutline { color: Some(Rgb::parse(x).ok_or_else(|| CmdError::Params("`outline`: a colour".into()))?), ..d }))
|
||||
}
|
||||
Some(o @ Value::Object(_)) => Some(Some(TextOutline {
|
||||
color: match o.get("color") {
|
||||
Some(Value::Null) => None,
|
||||
Some(Value::String(s)) if s.eq_ignore_ascii_case("none") => None,
|
||||
Some(_) => color(o, "color").or(d.color),
|
||||
None => d.color,
|
||||
},
|
||||
@@ -123,7 +124,7 @@ fn parse_shadow(v: Option<&Value>) -> Result<Change<Shadow>, CmdError> {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Bool(b)) => Some(b.then(Shadow::default)),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(Value::String(id)) if id.eq_ignore_ascii_case("none") => Some(None),
|
||||
Some(Value::String(id)) => Some(Some(preset(id)?)),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let mut sh = match p::str(o, "preset") {
|
||||
@@ -146,7 +147,7 @@ fn parse_glow(v: Option<&Value>, accent: Rgb) -> Result<Change<Glow>, CmdError>
|
||||
Ok(match v {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(x)) if x == "none" => Some(None),
|
||||
Some(Value::String(x)) if x.eq_ignore_ascii_case("none") => Some(None),
|
||||
Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..d }.sanitized()).filter(|g| g.size > 0.0)),
|
||||
Some(o @ Value::Object(_)) => Some(
|
||||
Some(
|
||||
@@ -169,13 +170,14 @@ fn parse_reflection(v: Option<&Value>) -> Result<Change<Reflection>, CmdError> {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Bool(b)) => Some(b.then_some(d)),
|
||||
Some(Value::String(x)) if x.eq_ignore_ascii_case("none") => Some(None),
|
||||
Some(o @ Value::Object(_)) => Some(Some(Reflection {
|
||||
transparency: p::f32(o, "transparency").unwrap_or(d.transparency),
|
||||
size: p::f32(o, "size").unwrap_or(d.size),
|
||||
distance: p::f32(o, "distance").unwrap_or(d.distance),
|
||||
blur: p::f32(o, "blur").unwrap_or(d.blur),
|
||||
})),
|
||||
Some(_) => return Err(CmdError::Params("`reflection`: true, an object or null".into())),
|
||||
Some(_) => return Err(CmdError::Params("`reflection`: true, \"none\", an object or null".into())),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -394,13 +394,15 @@ pub struct Session {
|
||||
typing_open: bool,
|
||||
/// The next mutating command joins the previous undo step (later frames of a drag).
|
||||
join_next: bool,
|
||||
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
|
||||
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
|
||||
rev: u64,
|
||||
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
|
||||
/// mail merge, recover), never on an edit.
|
||||
document_id: u64,
|
||||
cache: LayoutCache,
|
||||
/// (revision, width, read height, view, read line spacing, layout, proofing, outline,
|
||||
/// (deletions left out, body deletions in balloons), numeral display).
|
||||
/// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display).
|
||||
#[allow(clippy::type_complexity)]
|
||||
layout: Option<(
|
||||
u64,
|
||||
@@ -411,7 +413,7 @@ pub struct Session {
|
||||
Arc<DocLayout>,
|
||||
bool,
|
||||
Option<wordcraft_layout::outline::OutlineView>,
|
||||
(bool, bool),
|
||||
(bool, wordcraft_layout::HideDeleted, bool),
|
||||
NumeralMode,
|
||||
)>,
|
||||
/// Picture edits: edited media key → original media key (Reset Picture).
|
||||
@@ -628,6 +630,7 @@ impl Session {
|
||||
redo: Vec::new(),
|
||||
typing_open: false,
|
||||
join_next: false,
|
||||
autocap: None,
|
||||
rev: 1,
|
||||
document_id: 1,
|
||||
cache: LayoutCache::new(),
|
||||
@@ -784,8 +787,8 @@ impl Session {
|
||||
let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None };
|
||||
// Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so
|
||||
// a view change that moves them in or out of the text lays it out again.
|
||||
let hide_deleted = !self.view.show_markup || self.prefs.markup.hides_deletions();
|
||||
let deleted = (hide_deleted, self.revisions_in_balloons());
|
||||
let hide_deleted = !self.view.show_markup;
|
||||
let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons());
|
||||
let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view());
|
||||
if let Some((r, w, h, m, sp, l, pf, o, hd, n)) = &self.layout
|
||||
&& *r == self.rev
|
||||
@@ -807,7 +810,8 @@ impl Session {
|
||||
read_line_spacing: spacing.map(f32::from_bits),
|
||||
show_hidden: self.view.marks || self.prefs.marks.hidden,
|
||||
hide_deleted,
|
||||
deletions_in_balloons: deleted.1,
|
||||
hide_marked: deleted.1,
|
||||
deletions_in_balloons: deleted.2,
|
||||
proofing: self.view.proofing,
|
||||
numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)),
|
||||
comment_balloons: false,
|
||||
@@ -904,6 +908,17 @@ impl Session {
|
||||
core.last_modified_by = self.author.clone();
|
||||
core.revision = core.revision.saturating_add(1);
|
||||
}
|
||||
/// Whether typing is still collecting into one undo step (no other command since).
|
||||
pub fn typing_open(&self) -> bool {
|
||||
self.typing_open
|
||||
}
|
||||
/// Reopen the typing group when the last undo step is typing, so the keys typed next join
|
||||
/// it: after an input method's rewrite was joined to it (`join_next_undo` closes the group).
|
||||
pub fn continue_typing(&mut self) {
|
||||
if self.undo_label() == Some("Typing") {
|
||||
self.typing_open = true;
|
||||
}
|
||||
}
|
||||
/// Close an open typing group (caret moved, other command).
|
||||
pub fn close_typing(&mut self) {
|
||||
self.typing_open = false;
|
||||
@@ -920,6 +935,10 @@ impl Session {
|
||||
pub fn redo_label(&self) -> Option<&str> {
|
||||
self.redo.last().map(|u| u.label.as_str())
|
||||
}
|
||||
/// Undo steps pushed so far, evicted ones included: only grows while steps are added.
|
||||
pub(crate) fn undo_steps_so_far(&self) -> u64 {
|
||||
self.history.len() as u64 + self.undo_evicted
|
||||
}
|
||||
/// The number of undo steps.
|
||||
pub fn undo_depth(&self) -> usize {
|
||||
self.history.len()
|
||||
@@ -944,6 +963,12 @@ impl Session {
|
||||
}
|
||||
pub fn undo(&mut self) -> bool {
|
||||
self.typing_open = false;
|
||||
// Undoing the automatic capitalisation's own step makes its word an exception; any
|
||||
// other Undo forgets the capital.
|
||||
let steps = self.history.len() as u64 + self.undo_evicted;
|
||||
if let Some(c) = self.autocap.take().filter(|c| c.depth == steps) {
|
||||
c.add_exceptions(&mut self.prefs.autocorrect);
|
||||
}
|
||||
let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false };
|
||||
let cur = Undo {
|
||||
label: u.label.clone(),
|
||||
@@ -977,6 +1002,7 @@ impl Session {
|
||||
}
|
||||
/// Drop history (after open / new).
|
||||
pub fn reset_history(&mut self) {
|
||||
self.autocap = None;
|
||||
self.history.clear();
|
||||
self.redo.clear();
|
||||
self.typing_open = false;
|
||||
@@ -1009,6 +1035,7 @@ impl Session {
|
||||
|
||||
/// Replace the document (open/new).
|
||||
pub fn set_document(&mut self, doc: Document) {
|
||||
self.autocap = None;
|
||||
self.document_id = self.document_id.wrapping_add(1);
|
||||
self.bib_style = doc_bib_style(&doc);
|
||||
self.doc = doc;
|
||||
@@ -1079,6 +1106,7 @@ impl Session {
|
||||
redo,
|
||||
typing_open,
|
||||
join_next,
|
||||
autocap: _,
|
||||
rev: _,
|
||||
cache: _,
|
||||
layout: _,
|
||||
@@ -1143,6 +1171,7 @@ impl Session {
|
||||
self.history.splice(0..0, head.into_iter().take(back));
|
||||
}
|
||||
self.undo_evicted = evicted;
|
||||
self.autocap = None;
|
||||
self.doc = doc;
|
||||
self.sel = sel;
|
||||
self.also_selected.clear();
|
||||
@@ -1300,6 +1329,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.autocap = None;
|
||||
if let Some((d, s, h, r, t, ev, c)) = before_doc {
|
||||
self.doc = d;
|
||||
self.sel = s;
|
||||
@@ -1324,8 +1354,18 @@ impl Session {
|
||||
self.sel.focus.story
|
||||
}
|
||||
|
||||
/// Formatting typing would use at the caret.
|
||||
/// Formatting typing would use at the caret: the caret's, except run direction. New text
|
||||
/// takes its direction from its own letters, as on screen, so Latin typed or inserted into
|
||||
/// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces
|
||||
/// itself (#1543). The bidi language stays (it only applies to complex-script text).
|
||||
pub fn typing_props(&self) -> CharProps {
|
||||
let mut c = self.caret_props();
|
||||
c.rtl = None;
|
||||
c
|
||||
}
|
||||
|
||||
/// The caret's formatting, as Reveal Formatting shows it.
|
||||
pub fn caret_props(&self) -> CharProps {
|
||||
if let Some(p) = &self.pending {
|
||||
return p.clone();
|
||||
}
|
||||
|
||||
@@ -1935,6 +1935,20 @@ fn layout_size_refuses_a_page_its_margins_dont_fit() {
|
||||
assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h), (180.0, 180.0), "exactly 36 pt of room is enough");
|
||||
}
|
||||
|
||||
/// A negative margin (or gutter) counts as zero, so it can't offset an oversized one past the
|
||||
/// text-room check: `layout.margins` refuses and changes nothing.
|
||||
#[test]
|
||||
fn layout_margins_negative_cannot_hide_an_oversized_margin() {
|
||||
let mut s = s();
|
||||
run(&mut s, "layout.margins", json!({"preset": "normal"}));
|
||||
let before = s.doc.last_section.clone();
|
||||
for v in [json!({"top": 2000, "bottom": -2000}), json!({"left": 2000, "right": -2000}), json!({"left": 600, "right": 0, "gutter": -600})] {
|
||||
let e = s.run("layout.margins", &v).unwrap_err().to_string();
|
||||
assert!(e.contains("margins leave no room for text"), "{v}: {e}");
|
||||
assert_eq!(s.doc.last_section, before, "{v} changes nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1433: the smallest page there can be (0.1" wide, no margins, set straight through Page
|
||||
/// Setup's section) still lays out and exports text, a table and two columns.
|
||||
#[test]
|
||||
@@ -4290,6 +4304,26 @@ fn navigation_pane_opens_without_changing_view_mode() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_theme_queues_a_front_end_request_and_rejects_junk() {
|
||||
// #1861: the session can't paint, so the command asks the front end; the document,
|
||||
// view state and undo stack are untouched.
|
||||
let mut s = s();
|
||||
let dirty = s.dirty;
|
||||
for value in ["light", "dark", "system"] {
|
||||
let r = run(&mut s, "view.interfaceTheme", json!({"value": value}));
|
||||
assert_eq!(r["value"], value);
|
||||
assert_eq!(s.ui_requests.last().unwrap()["interfaceTheme"], value);
|
||||
}
|
||||
let r = run(&mut s, "view.interfaceTheme", json!({}));
|
||||
assert_eq!(r["value"], "toggle");
|
||||
assert_eq!(s.ui_requests.last().unwrap()["toggle"], "interfaceTheme");
|
||||
assert_eq!(s.dirty, dirty);
|
||||
for bad in [json!({"value": "midnight"}), json!({"value": 3})] {
|
||||
assert!(s.run("view.interfaceTheme", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -269,3 +269,56 @@ fn typing_kurdish_in_new_document_advances_caret_smoothly() {
|
||||
assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall");
|
||||
assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left");
|
||||
}
|
||||
|
||||
/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text).
|
||||
fn saved_runs(s: &Session) -> Vec<(bool, String)> {
|
||||
use std::io::Read;
|
||||
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let para = xml.split("<w:body>").nth(1).unwrap().split("</w:p>").next().unwrap().to_string();
|
||||
para.split("<w:r>")
|
||||
.skip(1)
|
||||
.map(|r| {
|
||||
let r = r.split("</w:r>").next().unwrap();
|
||||
let text: String = r.split("<w:t xml:space=\"preserve\">").skip(1).filter_map(|t| t.split("</w:t>").next()).collect();
|
||||
(r.contains("<w:rtl/>"), text)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_typed_into_reopened_hebrew_is_saved_left_to_right() {
|
||||
// #1543: a saved file's Hebrew runs say `w:rtl`. Latin typed next to them after reopening
|
||||
// takes its direction from its own letters, as on screen, so it's saved in a run of its own
|
||||
// without `w:rtl` (Word would otherwise lay it out right to left again).
|
||||
let s = session("לפני אחרי", true);
|
||||
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
|
||||
let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap());
|
||||
assert!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().runs.iter().all(|r| r.props.rtl == Some(true)));
|
||||
s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len()));
|
||||
run(&mut s, "text.insert", json!({"text": "Foo Bar 2026 "}));
|
||||
let runs = saved_runs(&s);
|
||||
assert!(runs.contains(&(false, "Foo Bar 2026".to_string())), "{runs:?}");
|
||||
assert!(runs.iter().all(|(rtl, t)| !rtl || !t.chars().any(|c| c.is_ascii_alphanumeric())), "{runs:?}");
|
||||
assert_eq!(runs.iter().map(|(_, t)| t.as_str()).collect::<String>(), "לפני Foo Bar 2026 אחרי");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_latin_link_inserted_into_reopened_hebrew_is_saved_left_to_right() {
|
||||
// Other insertions start from the caret's formatting too (#1543): a link's Latin text
|
||||
// inserted between reopened Hebrew words isn't saved as right-to-left.
|
||||
let s = session("לפני אחרי", true);
|
||||
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
|
||||
let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap());
|
||||
s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len()));
|
||||
run(&mut s, "insert.link", json!({"url": "https://example.com", "text": "WordCraft site"}));
|
||||
let runs = saved_runs(&s);
|
||||
assert!(runs.contains(&(false, "WordCraft site".to_string())), "{runs:?}");
|
||||
// The caret's own formatting, as Reveal Formatting shows it, still says right to left.
|
||||
s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפ".len()));
|
||||
s.pending = None; // moving the caret by hand doesn't drop the link's pending formatting
|
||||
assert_eq!(s.caret_props().rtl, Some(true));
|
||||
assert_eq!(s.typing_props().rtl, None);
|
||||
assert_eq!(s.typing_props().lang_bidi.as_deref(), Some("he-IL"), "the bidi language stays");
|
||||
}
|
||||
@@ -356,6 +356,39 @@ fn smart_quotes_nested_and_opening_delimiters() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "«‘french’» „‘german’“");
|
||||
}
|
||||
|
||||
/// #2223: the double-hyphen arrow is a replacement, not the existing arrow bullet.
|
||||
#[test]
|
||||
fn double_hyphen_arrow_autocorrect_preserves_typing_scope_and_undo() {
|
||||
let mut s = typed("go --> ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → ");
|
||||
assert_eq!(paras(&s)[0].1, None);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go --> ");
|
||||
let s = typed("go --> here");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here");
|
||||
let s = typed("--> ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "→ ");
|
||||
assert_eq!(paras(&s)[0].1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_hyphen_arrow_respects_autocorrect_preferences() {
|
||||
for prefs in [json!({"enabled": false}), json!({"replaceText": false}), json!({"delete": "-->"})] {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", prefs);
|
||||
typ(&mut s, "go --> here");
|
||||
assert!(s.doc.plain_text(StoryRef::Body).ends_with(" --> here"));
|
||||
}
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
typ(&mut s, "go --> here");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"add": {"from": "-->", "to": "custom"}}));
|
||||
typ(&mut s, "go --> here");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go custom here");
|
||||
}
|
||||
|
||||
/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays
|
||||
/// lowercase; symbols still change; English and untagged text still get "I".
|
||||
#[test]
|
||||
@@ -1385,3 +1418,434 @@ fn delete_removes_a_section_break_on_the_last_paragraph() {
|
||||
assert_eq!((sections[0].1.headers.default, sections[1].1.headers.default), (header, None), "{then:?}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
fn first_letter_list(s: &Session) -> Vec<String> {
|
||||
s.prefs.autocorrect.exceptions.clone()
|
||||
}
|
||||
|
||||
/// Undoing an automatic sentence capital adds the word before it to the First Letter exceptions
|
||||
/// (Word: "Automatically add words to list", #1857); typing the line again isn't capitalised.
|
||||
#[test]
|
||||
fn undoing_a_sentence_capital_adds_a_first_letter_exception() {
|
||||
let mut s = typed("hello there. world ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World ");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world ");
|
||||
assert_eq!(first_letter_list(&s), ["there."]);
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
typ(&mut s, "x there. world ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world \nX there. world ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception() {
|
||||
let mut s = typed("hello there. world ");
|
||||
typ(&mut s, "\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world ");
|
||||
assert_eq!(first_letter_list(&s), ["there."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_exceptions_can_be_turned_off() {
|
||||
let mut s = typed("");
|
||||
run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false}));
|
||||
typ(&mut s, "hello there. world ");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(first_letter_list(&s).is_empty());
|
||||
let mut s = typed("");
|
||||
run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false}));
|
||||
typ(&mut s, "hello there. world \u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World ");
|
||||
assert!(first_letter_list(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_replacement_adds_no_exception() {
|
||||
let mut s = typed("so teh ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So the ");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh ");
|
||||
assert!(first_letter_list(&s).is_empty());
|
||||
assert!(s.prefs.autocorrect.entries.is_empty() && s.prefs.autocorrect.removed.is_empty());
|
||||
typ(&mut s, "and teh ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh and the ");
|
||||
}
|
||||
|
||||
/// Checked in Word for Mac: the same rule as First Letter.
|
||||
#[test]
|
||||
fn undoing_an_initial_caps_fix_adds_an_initial_caps_exception() {
|
||||
let mut s = typed("so TWo ");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.prefs.autocorrect.initial_caps_exceptions, ["TWo"]);
|
||||
typ(&mut s, "and TWo ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So TWo and TWo ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_other_edits_after_a_capital_adds_no_exception() {
|
||||
// Typing more, then one Undo, and a second Undo that reverses the capital's step later.
|
||||
let mut s = typed("hello there. world more text");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(first_letter_list(&s).is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(first_letter_list(&s).is_empty(), "only the Undo that reverses the capital itself counts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_undo_history_does_not_match_later_undos() {
|
||||
let mut s = typed("");
|
||||
for _ in 0..300 {
|
||||
typ(&mut s, "x\n");
|
||||
}
|
||||
typ(&mut s, "hello there. world more text\nand more");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(first_letter_list(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_forgets_the_capital() {
|
||||
let mut s = typed("hello there. world ");
|
||||
run(&mut s, "file.new", json!({}));
|
||||
typ(&mut s, "abc\ndef\nghi\njkl");
|
||||
for _ in 0..8 {
|
||||
let _ = s.run("edit.undo", &json!({}));
|
||||
}
|
||||
assert!(first_letter_list(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_word_is_not_added_as_an_exception() {
|
||||
let mut s = typed(&format!("hi {}. world ", "a".repeat(5000)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(first_letter_list(&s).is_empty());
|
||||
}
|
||||
|
||||
/// Each body paragraph's laid-out number (`None`: none) and its style.
|
||||
fn numbers(s: &mut Session) -> Vec<(Option<String>, String)> {
|
||||
let l = s.layout();
|
||||
let labels: Vec<Option<String>> = l
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.items)
|
||||
.filter_map(|i| {
|
||||
if let wordcraft_layout::Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None }
|
||||
})
|
||||
.collect();
|
||||
let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into()));
|
||||
labels.into_iter().zip(styles).collect()
|
||||
}
|
||||
|
||||
fn num(label: Option<&str>, style: &str) -> (Option<String>, String) {
|
||||
(label.map(str::to_string), style.to_string())
|
||||
}
|
||||
|
||||
/// #1793 (Word for Mac, 2026-10-15): after the "1 / 1.1 / 1.1.1" heading list, a new Heading 2
|
||||
/// typed after "Three" is numbered 2.1 on its own; the body text after it stays unnumbered.
|
||||
#[test]
|
||||
fn a_new_heading_after_a_heading_linked_list_is_numbered() {
|
||||
let mut s = s();
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
typ(&mut s, "\nTwo");
|
||||
run(&mut s, "para.heading2", json!({}));
|
||||
typ(&mut s, "\nThree");
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
// The caret in "Three" (a Heading 1, the list's first level).
|
||||
run(&mut s, "list.multilevelPreset", json!({"preset": "headings"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
typ(&mut s, "\n");
|
||||
run(&mut s, "para.heading2", json!({}));
|
||||
typ(&mut s, "Four\nBody");
|
||||
assert_eq!(
|
||||
numbers(&mut s),
|
||||
[num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2"), num(Some("2"), "Heading1"), num(Some("2.1"), "Heading2"), num(None, "Normal")]
|
||||
);
|
||||
}
|
||||
|
||||
/// #1793: plain Numbering on a Heading 1 numbers it "1." and keeps Heading 1 (Word: "1. One").
|
||||
#[test]
|
||||
fn numbering_a_heading_keeps_the_heading_style() {
|
||||
let mut s = s();
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1")]);
|
||||
}
|
||||
|
||||
/// #1793: applying a heading style that carries list numbering numbers the paragraph, even one
|
||||
/// whose numbering was switched off before.
|
||||
#[test]
|
||||
fn applying_a_numbered_heading_style_numbers_the_paragraph() {
|
||||
let mut s = s();
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
run(&mut s, "list.multilevelPreset", json!({"preset": "headings"}));
|
||||
typ(&mut s, "\nTwo");
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
run(&mut s, "para.numbering", json!({"off": true}));
|
||||
assert_eq!(numbers(&mut s)[1].0, None);
|
||||
run(&mut s, "para.heading2", json!({}));
|
||||
assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]);
|
||||
}
|
||||
|
||||
/// #1793: "1. " typed at the start of a heading numbers it and keeps the heading style; only
|
||||
/// body text becomes List Paragraph.
|
||||
#[test]
|
||||
fn typing_1_dot_in_a_heading_keeps_the_heading_style() {
|
||||
let mut s = s();
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
typ(&mut s, "1. One\n");
|
||||
run(&mut s, "para.normal", json!({}));
|
||||
typ(&mut s, "1. Body");
|
||||
assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1"), num(Some("1."), "ListParagraph")]);
|
||||
for (style, keys) in [("Heading2", "a) Part"), ("Subtitle", "1. Item")] {
|
||||
let mut s = self::s();
|
||||
run(&mut s, "para.style", json!({"style": style}));
|
||||
typ(&mut s, keys);
|
||||
let got = numbers(&mut s);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!(got[0].0.is_some(), "{style}: a list");
|
||||
assert_eq!(got[0].1, style);
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace and Delete on selected tables, rows and columns (Word for Mac, #1844).
|
||||
|
||||
/// "before", a 3×3 table with cells `a1`…`c3` (column letter, row number), "after"; the caret
|
||||
/// in the cell at row `r`, column `c`.
|
||||
fn table_between(r: u32, c: u32) -> Session {
|
||||
let mut s = typed("before\n");
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 3}));
|
||||
typ(&mut s, "a1\tb1\tc1\ta2\tb2\tc2\ta3\tb3\tc3");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
typ(&mut s, "after");
|
||||
let shape: Vec<&str> = s.doc.body.iter().map(|b| if b.as_table().is_some() { "table" } else { "para" }).collect();
|
||||
assert_eq!(shape, ["para", "table", "para"], "setup");
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, 0], "off": 0}}));
|
||||
s
|
||||
}
|
||||
|
||||
fn tables(s: &Session) -> usize {
|
||||
s.doc.body.iter().filter(|b| b.as_table().is_some()).count()
|
||||
}
|
||||
|
||||
/// The first top-level table's cells, row by row (tracked deletions included).
|
||||
fn table_cells(s: &Session) -> Vec<Vec<String>> {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.find_map(|b| b.as_table())
|
||||
.map(|t| {
|
||||
t.rows
|
||||
.iter()
|
||||
.map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_with_the_whole_table_selected_removes_the_table() {
|
||||
let mut s = table_between(1, 1);
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(tables(&s), 0, "the table stays: {:?}", table_cells(&s));
|
||||
assert_eq!(texts(&s), ["before", "after"]);
|
||||
// The caret is in the paragraph that followed the table.
|
||||
assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 0 });
|
||||
assert!(s.sel.is_collapsed());
|
||||
// One undo brings it back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(tables(&s), 1);
|
||||
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table() {
|
||||
let mut s = table_between(0, 0);
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(tables(&s), 1);
|
||||
assert_eq!(table_cells(&s), [["", "", ""], ["", "", ""], ["", "", ""]]);
|
||||
assert_eq!(texts(&s), ["before", "after"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table() {
|
||||
for key in ["text.delete", "text.backspace"] {
|
||||
// From the first cell to the end of the document: Word's rule widens it to whole rows.
|
||||
let mut s = table_between(0, 0);
|
||||
run(&mut s, "caret.docEnd", json!({"extend": true}));
|
||||
run(&mut s, key, json!({}));
|
||||
assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s));
|
||||
assert_eq!(texts(&s), ["before", ""], "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table() {
|
||||
for key in ["text.delete", "text.backspace"] {
|
||||
// From the start of "before" down into the last row: the rows are taken whole.
|
||||
let mut s = table_between(0, 0);
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
for _ in 0..3 {
|
||||
run(&mut s, "caret.down", json!({"extend": true}));
|
||||
}
|
||||
assert_eq!(s.sel.focus.path.cell().map(|(_, r, _)| r), Some(2), "{key}: the selection reaches the last row");
|
||||
run(&mut s, key, json!({}));
|
||||
assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s));
|
||||
assert_eq!(texts(&s), ["after"], "{key}");
|
||||
assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }, "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_selected_rows_and_delete_clears_them() {
|
||||
let mut s = table_between(1, 0);
|
||||
run(&mut s, "table.selectRow", json!({}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a3", "b3", "c3"]]);
|
||||
|
||||
let mut s = table_between(1, 0);
|
||||
run(&mut s, "table.selectRow", json!({}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["", "", ""], ["a3", "b3", "c3"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_selected_columns_and_delete_clears_them() {
|
||||
let mut s = table_between(0, 1);
|
||||
run(&mut s, "table.selectColumn", json!({}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(table_cells(&s), [["a1", "c1"], ["a2", "c2"], ["a3", "c3"]]);
|
||||
|
||||
let mut s = table_between(0, 1);
|
||||
run(&mut s, "table.selectColumn", json!({}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_on_a_selected_nested_table_removes_only_it() {
|
||||
let mut s = table_between(1, 1);
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
typ(&mut s, "x");
|
||||
let cell_blocks = |s: &Session| {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.find_map(|b| b.as_table())
|
||||
.and_then(|t| t.rows.get(1)?.cells.get(1))
|
||||
.map(|c| c.blocks.iter().filter(|b| b.as_table().is_some()).count())
|
||||
};
|
||||
assert_eq!(cell_blocks(&s), Some(1), "setup: a table nested in b2");
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(cell_blocks(&s), Some(0), "the nested table stays");
|
||||
assert_eq!(tables(&s), 1, "the outer table goes too");
|
||||
// The caret is in the cell's paragraph after where the nested table was.
|
||||
assert_eq!(s.sel.focus.path.cell().map(|(t, r, c)| (t.0, r, c)), Some((vec![1], 1, 1)));
|
||||
assert!(s.doc.para_at(&s.sel.focus).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
typ(&mut s, "x");
|
||||
s.doc.body.retain(|b| b.as_table().is_some());
|
||||
assert_eq!(s.doc.body.len(), 1, "setup: the table is the only block");
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(tables(&s), 0);
|
||||
assert_eq!(texts(&s), [""]);
|
||||
assert!(s.doc.para_at(&s.sel.focus).is_some(), "the caret is in a paragraph: {:?}", s.sel.focus);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table() {
|
||||
let mut s = table_between(0, 0);
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
// Like Table Layout › Delete Table with tracking on: the structure stays, the text is marked deleted.
|
||||
assert_eq!(tables(&s), 1);
|
||||
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]);
|
||||
assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforeafter");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforea1b1c1a2b2c2a3b3c3after");
|
||||
assert!(s.doc.revisions.is_empty());
|
||||
}
|
||||
|
||||
/// One selected cell isn't a whole row or column, even in a one-row or one-column table:
|
||||
/// Backspace clears it (Word's cell selection has no end-of-row mark).
|
||||
#[test]
|
||||
fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() {
|
||||
for (rows, cols, at) in [(1, 3, [0, 1]), (3, 1, [1, 0])] {
|
||||
let mut s = typed("before\n");
|
||||
run(&mut s, "insert.table", json!({"rows": rows, "cols": cols}));
|
||||
typ(&mut s, "x\ty\tz");
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, at[0], at[1], 0], "off": 0}}));
|
||||
run(&mut s, "table.selectCell", json!({}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
let want: Vec<Vec<String>> =
|
||||
if rows == 1 { vec![vec!["x".into(), "".into(), "z".into()]] } else { vec![vec!["x".into()], vec!["".into()], vec!["z".into()]] };
|
||||
assert_eq!(table_cells(&s), want, "{rows}×{cols}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1670: smart quotes in Hebrew follow the characters around the quote in logical order, as
|
||||
/// Word does (Word for Mac 16.113.4): an opening “ after a space or at the start, a closing ”
|
||||
/// after a letter, and ” inside an acronym (צה"ל), not gershayim; ' after a letter is ’.
|
||||
#[test]
|
||||
fn smart_quotes_in_hebrew_use_logical_order() {
|
||||
let bidi = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).next().and_then(|p| p.props.bidi);
|
||||
let s = typed("הוא אמר \"שלום\" לי");
|
||||
assert_eq!(bidi(&s), Some(true), "a right-to-left paragraph");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "הוא אמר “שלום” לי");
|
||||
let s = typed("\"שלום\"");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "“שלום”");
|
||||
let s = typed("צה\"ל ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "צה”ל ");
|
||||
let s = typed("ג'ירפה 'כן'");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "ג’ירפה ‘כן’");
|
||||
}
|
||||
|
||||
/// #1923: Turkish and Azerbaijani case mapping (i↔İ, ı↔I) in AutoCorrect, checked against Word
|
||||
/// with Turkish proofing; other languages keep the Unicode default.
|
||||
#[test]
|
||||
fn autocorrect_capitalisation_follows_turkic_case_mapping() {
|
||||
for tag in ["tr-TR", "az-AZ"] {
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
|
||||
typ(&mut s, "istanbul güzel. ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul güzel. ", "{tag}: i capitalises to İ");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
|
||||
typ(&mut s, "İNgilizce ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "{tag}: İ kept, N lowercased, no combining dot");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
|
||||
typ(&mut s, "ılık ırmak. ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ilık ırmak. ", "{tag}: ı capitalises to I");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
|
||||
typ(&mut s, "iSTANBUL ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul ", "{tag}: cAPS LOCK fix");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
|
||||
typ(&mut s, "kızım. TIklamak ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Kızım. Tıklamak ", "{tag}: TWo INitial CApitals lowercases I to ı");
|
||||
}
|
||||
// Other languages are unchanged; a stray İ still lowercases to a single "i".
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": "en-US", "default": true}));
|
||||
typ(&mut s, "istanbul güzel. ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul güzel. ");
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": "en-US", "default": true}));
|
||||
typ(&mut s, "İNgilizce ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language");
|
||||
}
|
||||
@@ -139,3 +139,27 @@ fn wordart_is_inserted_styled_and_bent() {
|
||||
);
|
||||
run(&mut s, "wordArt.transform", json!({"preset": "none"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordart_reflection_and_outline_none_options() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let r = run(&mut s, "insert.wordArt", json!({"text": "Reflect", "style": "solid"}));
|
||||
let id = r["story"].as_u64().unwrap() as u32;
|
||||
|
||||
// Setting reflection
|
||||
run(&mut s, "format.textEffects", json!({"reflection": true}));
|
||||
let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone();
|
||||
assert!(props.text_effects.as_ref().unwrap().reflection.is_some());
|
||||
|
||||
// Clearing reflection using "none"
|
||||
run(&mut s, "format.textEffects", json!({"reflection": "none"}));
|
||||
let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone();
|
||||
assert!(props.text_effects.as_ref().and_then(|fx| fx.reflection).is_none());
|
||||
|
||||
// Setting outline with color "none"
|
||||
run(&mut s, "wordArt.textOutline", json!({"color": "none", "width": 1.5}));
|
||||
let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone();
|
||||
let outline = props.text_effects.as_ref().unwrap().outline.unwrap();
|
||||
assert_eq!(outline.color, None);
|
||||
assert_eq!(outline.width, 1.5);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
mod user;
|
||||
pub(crate) mod user;
|
||||
pub use user::{FONT_FILE_EXTS, FontFormat, Installed, MAX_FONT_FILE_BYTES, UserFont, UserFontError, decode_font_file, is_font_file_name, sniff};
|
||||
|
||||
/// The family used when a requested family is unknown (the UI sans).
|
||||
|
||||
@@ -422,7 +422,7 @@ fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
const FAMILY: &str = "Userfont Sans";
|
||||
|
||||
+101
-6
@@ -299,15 +299,35 @@ pub const MATH_SUBSTITUTES: &[&str] = &[
|
||||
"Noto Sans Math",
|
||||
];
|
||||
|
||||
const MATH_CACHE_MAX: usize = 1024;
|
||||
|
||||
/// Math names longer than this (bytes) are resolved but never remembered.
|
||||
const MATH_KEY_MAX: usize = 256;
|
||||
|
||||
/// Resolutions tagged with the [`FontDb::generation`] they were made at.
|
||||
type MathMemo = (u64, HashMap<String, (FaceRef, Option<Arc<MathTable>>)>);
|
||||
|
||||
fn math_cache() -> &'static Mutex<MathMemo> {
|
||||
static CACHE: OnceLock<Mutex<MathMemo>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new((0, HashMap::new())))
|
||||
}
|
||||
|
||||
/// The face to set math in: `family` when installed and it has a `MATH` table, else the first
|
||||
/// installed substitute that has one, else `family` (or its text substitute) without one.
|
||||
/// Remembered until user fonts are added or removed ([`FontDb::generation`]).
|
||||
pub fn resolve_math(family: &str) -> (FaceRef, Option<Arc<MathTable>>) {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, (FaceRef, Option<Arc<MathTable>>)>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(family) {
|
||||
return v.clone();
|
||||
}
|
||||
let cache = math_cache();
|
||||
let db = FontDb::global();
|
||||
let generation = db.generation();
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.0 != generation {
|
||||
*c = (generation, HashMap::new());
|
||||
}
|
||||
if let Some(v) = c.1.get(family) {
|
||||
return v.clone();
|
||||
}
|
||||
}
|
||||
let mut found = None;
|
||||
for fam in std::iter::once(family).chain(MATH_SUBSTITUTES.iter().copied()) {
|
||||
if !db.has_family(fam) {
|
||||
@@ -322,7 +342,15 @@ pub fn resolve_math(family: &str) -> (FaceRef, Option<Arc<MathTable>>) {
|
||||
}
|
||||
}
|
||||
let v = found.unwrap_or_else(|| (crate::word::resolve(family, false, false).face, None));
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(family.to_string(), v.clone());
|
||||
if family.len() <= MATH_KEY_MAX {
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.0 == generation {
|
||||
if c.1.len() >= MATH_CACHE_MAX {
|
||||
c.1.clear();
|
||||
}
|
||||
c.1.insert(family.to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -330,6 +358,73 @@ pub fn resolve_math(family: &str) -> (FaceRef, Option<Arc<MathTable>>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cached(family: &str) -> bool {
|
||||
math_cache().lock().unwrap_or_else(|e| e.into_inner()).1.contains_key(family)
|
||||
}
|
||||
|
||||
/// `font` (an sfnt) with a minimal valid `MATH` table added: enough for `parse` to accept.
|
||||
fn with_math_table(font: &[u8]) -> Vec<u8> {
|
||||
let n = u16::from_be_bytes([font[4], font[5]]) as usize;
|
||||
let mut dir: Vec<[u8; 16]> = (0..n).map(|i| font[12 + i * 16..28 + i * 16].try_into().unwrap()).collect();
|
||||
let mut math = vec![0u8, 1, 0, 0, 0, 10, 0, 0, 0, 0];
|
||||
math.resize(10 + 8 + VALUE_COUNT * 4 + 2, 0);
|
||||
let data_start = 12 + (n + 1) * 16;
|
||||
let end = font.len() + 16;
|
||||
for e in &mut dir {
|
||||
let off = u32::from_be_bytes([e[8], e[9], e[10], e[11]]) + 16;
|
||||
e[8..12].copy_from_slice(&off.to_be_bytes());
|
||||
}
|
||||
let mut entry = [0u8; 16];
|
||||
entry[..4].copy_from_slice(b"MATH");
|
||||
entry[8..12].copy_from_slice(&(end as u32).to_be_bytes());
|
||||
entry[12..16].copy_from_slice(&(math.len() as u32).to_be_bytes());
|
||||
let at = dir.iter().position(|e| e[..4] > entry[..4]).unwrap_or(dir.len());
|
||||
dir.insert(at, entry);
|
||||
let mut out = font[..12].to_vec();
|
||||
out[4..6].copy_from_slice(&((n + 1) as u16).to_be_bytes());
|
||||
// Search fields are only hints for lookups; skrifa binary-searches the directory, so
|
||||
// keep them consistent.
|
||||
let entry_selector = (usize::BITS - 1 - (n + 1).leading_zeros()) as u16;
|
||||
let search_range = (1u16 << entry_selector) * 16;
|
||||
out[6..8].copy_from_slice(&search_range.to_be_bytes());
|
||||
out[8..10].copy_from_slice(&entry_selector.to_be_bytes());
|
||||
out[10..12].copy_from_slice(&(((n + 1) as u16) * 16 - search_range).to_be_bytes());
|
||||
for e in &dir {
|
||||
out.extend_from_slice(e);
|
||||
}
|
||||
debug_assert_eq!(out.len(), data_start);
|
||||
out.extend_from_slice(&font[12 + n * 16..]);
|
||||
out.extend_from_slice(&math);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_resolution_follows_installed_and_removed_fonts() {
|
||||
use crate::fontdb::user::tests::renamed;
|
||||
// 13 characters, as `renamed` requires.
|
||||
let fam = "Zz Math Gen 1";
|
||||
let db = FontDb::global();
|
||||
assert_ne!(resolve_math(fam).0.family, fam);
|
||||
let font = with_math_table(&renamed("SourceSans3-Regular.ttf", fam));
|
||||
db.install_font("ZzMathGen.ttf", &font).unwrap();
|
||||
let (face, table) = resolve_math(fam);
|
||||
assert_eq!(face.family, fam, "a math font added after the first lookup must be used");
|
||||
assert!(table.is_some());
|
||||
db.remove_user_font(fam).unwrap();
|
||||
assert_ne!(resolve_math(fam).0.family, fam, "a removed font must stop being used");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_cache_stays_bounded() {
|
||||
for i in 0..(MATH_CACHE_MAX + 50) {
|
||||
let _ = resolve_math(&format!("Zz Bound Probe {i}"));
|
||||
}
|
||||
assert!(math_cache().lock().unwrap_or_else(|e| e.into_inner()).1.len() <= MATH_CACHE_MAX);
|
||||
let long = "L".repeat(10_000);
|
||||
let _ = resolve_math(&long);
|
||||
assert!(!cached(&long), "absurdly long names must not be cached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_tables_never_panic() {
|
||||
assert!(parse(&[]).is_none());
|
||||
|
||||
@@ -310,21 +310,31 @@ fn style_name(bold: bool, italic: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
type Memo = (u64, HashMap<(String, bool, bool), Resolved>);
|
||||
|
||||
fn resolve_cache() -> &'static Mutex<Memo> {
|
||||
static CACHE: OnceLock<Mutex<Memo>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new((0, HashMap::new())))
|
||||
}
|
||||
|
||||
const FAMILY_CACHE_MAX: usize = 4096;
|
||||
/// Family names longer than this (bytes) are resolved but never remembered.
|
||||
const FAMILY_KEY_MAX: usize = 256;
|
||||
|
||||
/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed,
|
||||
/// [`FontDb::generation`]).
|
||||
pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
type Memo = (u64, HashMap<(String, bool, bool), Resolved>);
|
||||
static CACHE: OnceLock<Mutex<Memo>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new((0, HashMap::new())));
|
||||
let cache = resolve_cache();
|
||||
let db = FontDb::global();
|
||||
let generation = db.generation();
|
||||
let key = (family.to_string(), bold, italic);
|
||||
let cacheable = family.len() <= FAMILY_KEY_MAX;
|
||||
let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic);
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.0 != generation {
|
||||
*c = (generation, HashMap::new());
|
||||
}
|
||||
if let Some(r) = c.1.get(&key) {
|
||||
if cacheable && let Some(r) = c.1.get(&key) {
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
@@ -341,7 +351,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.0 == generation {
|
||||
if cacheable && c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
r
|
||||
@@ -394,6 +407,17 @@ mod tests {
|
||||
assert_eq!(hhea_line(1000, i16::MIN, 0), (1000.0, 32768.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_cache_stays_bounded() {
|
||||
for i in 0..(FAMILY_CACHE_MAX + 50) {
|
||||
let _ = resolve(&format!("Zz Missing Family {i}"), false, false);
|
||||
}
|
||||
assert!(resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.len() <= FAMILY_CACHE_MAX);
|
||||
let long = "L".repeat(10_000);
|
||||
let _ = resolve(&long, false, false);
|
||||
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > 1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_family_substitutes() {
|
||||
let r = resolve("Definitely Not A Font 123", false, false);
|
||||
|
||||
+106
-14
@@ -175,8 +175,16 @@ fn node(n: &MNode, o: &mut String, depth: usize, eq_arr: bool) {
|
||||
o.push_str("<mmultiscripts>");
|
||||
group(base, o, depth);
|
||||
o.push_str("<mprescripts/>");
|
||||
group(sub, o, depth);
|
||||
group(sup, o, depth);
|
||||
if sub.is_empty() {
|
||||
o.push_str("<none/>");
|
||||
} else {
|
||||
group(sub, o, depth);
|
||||
}
|
||||
if sup.is_empty() {
|
||||
o.push_str("<none/>");
|
||||
} else {
|
||||
group(sup, o, depth);
|
||||
}
|
||||
o.push_str("</mmultiscripts>");
|
||||
}
|
||||
},
|
||||
@@ -443,7 +451,8 @@ pub fn local(name: &str) -> &str {
|
||||
/// The linear format of a `<math>` element, and whether it's a display equation. `None` when it
|
||||
/// holds nothing.
|
||||
pub fn read(math: &El) -> Option<(String, bool)> {
|
||||
let display = math.attr("display").is_some_and(|d| d.eq_ignore_ascii_case("block")) || math.attr("mode").is_some_and(|d| d == "display");
|
||||
let display =
|
||||
math.attr("display").is_some_and(|d| d.eq_ignore_ascii_case("block")) || math.attr("mode").is_some_and(|d| d.eq_ignore_ascii_case("display"));
|
||||
let linear = linear_of(math);
|
||||
let linear: String = linear.chars().filter(|c| !c.is_control()).collect();
|
||||
let linear = linear.trim().to_string();
|
||||
@@ -636,7 +645,20 @@ fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg {
|
||||
Some(p) => {
|
||||
let sub = script(p + 1, budget);
|
||||
let sup = script(p + 2, budget);
|
||||
vec![MNode::Script { kind: ScriptKind::Pre, base, sub, sup }]
|
||||
let pre_node = MNode::Script { kind: ScriptKind::Pre, base, sub, sup };
|
||||
if p > 1 {
|
||||
let post_sub = script(1, budget);
|
||||
let post_sup = script(2, budget);
|
||||
let kind = match (post_sub.is_empty(), post_sup.is_empty()) {
|
||||
(true, true) => return vec![pre_node],
|
||||
(false, true) => ScriptKind::Sub,
|
||||
(true, false) => ScriptKind::Sup,
|
||||
(false, false) => ScriptKind::SubSup,
|
||||
};
|
||||
vec![MNode::Script { kind, base: vec![pre_node], sub: post_sub, sup: post_sup }]
|
||||
} else {
|
||||
vec![pre_node]
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let sub = script(1, budget);
|
||||
@@ -660,12 +682,9 @@ fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg {
|
||||
vec![MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }]
|
||||
}
|
||||
"mtable" => {
|
||||
let rows: Vec<Vec<Arg>> = e
|
||||
.els()
|
||||
.filter(|r| matches!(local(&r.name), "mtr" | "mlabeledtr"))
|
||||
.take(256)
|
||||
.map(|r| r.els().filter(|c| local(&c.name) == "mtd").take(256).map(|c| children(c, d, budget)).collect())
|
||||
.collect();
|
||||
let mtr_list: Vec<&El> = e.els().filter(|r| matches!(local(&r.name), "mtr" | "mlabeledtr")).take(256).collect();
|
||||
let rows: Vec<Vec<Arg>> =
|
||||
mtr_list.iter().map(|r| r.els().filter(|c| local(&c.name) == "mtd").take(256).map(|c| children(c, d, budget)).collect()).collect();
|
||||
if rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -676,14 +695,68 @@ fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg {
|
||||
if marked && rows.iter().all(|r| r.len() <= 1) {
|
||||
return vec![MNode::EqArr { rows: rows.into_iter().map(|mut r| r.pop().unwrap_or_default()).collect() }];
|
||||
}
|
||||
vec![MNode::Matrix { rows, col_jc: Vec::new() }]
|
||||
let mut col_jc: Vec<ColJc> = e
|
||||
.attr("columnalign")
|
||||
.map(|ca| {
|
||||
ca.split_whitespace()
|
||||
.map(|a| match a {
|
||||
"left" => ColJc::Left,
|
||||
"right" => ColJc::Right,
|
||||
_ => ColJc::Center,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if col_jc.is_empty()
|
||||
&& let Some(first_row) = mtr_list.first()
|
||||
{
|
||||
let cell_aligns: Vec<ColJc> = first_row
|
||||
.els()
|
||||
.filter(|c| local(&c.name) == "mtd")
|
||||
.map(|c| match c.attr("columnalign") {
|
||||
Some("left") => ColJc::Left,
|
||||
Some("right") => ColJc::Right,
|
||||
_ => ColJc::Center,
|
||||
})
|
||||
.collect();
|
||||
if cell_aligns.iter().any(|a| *a != ColJc::Center) {
|
||||
col_jc = cell_aligns;
|
||||
}
|
||||
}
|
||||
vec![MNode::Matrix { rows, col_jc }]
|
||||
}
|
||||
"mpadded" => {
|
||||
let zero_wid = e.attr("width").is_some_and(|w| w.trim() == "0");
|
||||
let zero_asc = e.attr("height").is_some_and(|h| h.trim() == "0");
|
||||
let zero_desc = e.attr("depth").is_some_and(|d| d.trim() == "0");
|
||||
let ch = children(e, d, budget);
|
||||
if zero_wid || zero_asc || zero_desc {
|
||||
if let [MNode::Phant { show, e, .. }] = ch.as_slice() {
|
||||
vec![MNode::Phant { show: *show, zero_wid, zero_asc, zero_desc, e: e.clone() }]
|
||||
} else {
|
||||
vec![MNode::Phant { show: true, zero_wid, zero_asc, zero_desc, e: ch }]
|
||||
}
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
}
|
||||
"mphantom" => vec![MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: children(e, d, budget) }],
|
||||
"menclose" => {
|
||||
let n = e.attr("notation").unwrap_or("longdiv");
|
||||
let has = |k: &str| n.split_whitespace().any(|w| w == k);
|
||||
if has("box") || has("roundedbox") {
|
||||
return vec![MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: children(e, d, budget) }];
|
||||
let is_box = has("box") || has("roundedbox") || has("circle");
|
||||
let has_border = is_box || has("top") || has("bottom") || has("left") || has("right");
|
||||
let has_strike = has("horizontalstrike") || has("verticalstrike") || has("updiagonalstrike") || has("downdiagonalstrike");
|
||||
if has_border || has_strike {
|
||||
let hide = if is_box {
|
||||
[false; 4]
|
||||
} else if has_border {
|
||||
[!has("top"), !has("bottom"), !has("left"), !has("right")]
|
||||
} else {
|
||||
[true; 4]
|
||||
};
|
||||
let strike = [has("horizontalstrike"), has("verticalstrike"), has("updiagonalstrike"), has("downdiagonalstrike")];
|
||||
return vec![MNode::BorderBox { hide, strike, e: children(e, d, budget) }];
|
||||
}
|
||||
children(e, d, budget)
|
||||
}
|
||||
@@ -751,7 +824,7 @@ mod tests {
|
||||
let gc = to_mathml(&[MNode::GroupChr { chr: '⏟', top: false, e: parse_linear("a") }], false, "");
|
||||
assert!(gc.contains("<munder><mrow><mi>a</mi></mrow><mo stretchy=\"true\">⏟</mo></munder>"), "{gc}");
|
||||
let pre = to_mathml(&[MNode::Script { kind: ScriptKind::Pre, base: parse_linear("C"), sub: parse_linear("n"), sup: vec![] }], false, "");
|
||||
assert!(pre.contains("<mprescripts/>"), "{pre}");
|
||||
assert!(pre.contains("<mprescripts/>") && pre.contains("<none/>"), "{pre}");
|
||||
let eq = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x&=1"), parse_linear("y&=2")] }], false, "");
|
||||
assert!(eq.contains("<mtd><mi>x</mi><mo>=</mo><mn>1</mn></mtd>") && !eq.contains("<mo>&</mo>"), "{eq}");
|
||||
let ph = to_mathml(&[MNode::Phant { show: false, zero_wid: true, zero_asc: false, zero_desc: false, e: parse_linear("a") }], false, "");
|
||||
@@ -884,6 +957,25 @@ mod tests {
|
||||
assert!(matches!(t.as_slice(), [MNode::EqArr { .. }]), "{ml}: {t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mmultiscripts_and_borderbox_round_trip() {
|
||||
let html = "<math><mmultiscripts><mi>X</mi><mn>1</mn><none/><mprescripts/><none/><mn>2</mn></mmultiscripts></math>";
|
||||
let p = pasted(html);
|
||||
assert!(!p.is_empty(), "should parse mmultiscripts with pre and post scripts");
|
||||
|
||||
let html_box = "<math><menclose notation='top bottom horizontalstrike'><mi>x</mi></menclose></math>";
|
||||
let p_box = pasted(html_box);
|
||||
assert!(!p_box.is_empty(), "should parse menclose");
|
||||
|
||||
let html_tbl = "<math><mtable columnalign='left right'><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr></mtable></math>";
|
||||
let p_tbl = pasted(html_tbl);
|
||||
assert!(!p_tbl.is_empty(), "should parse mtable with columnalign");
|
||||
|
||||
let html_pad = "<math><mpadded width='0'><mphantom><mi>z</mi></mphantom></mpadded></math>";
|
||||
let p_pad = pasted(html_pad);
|
||||
assert!(!p_pad.is_empty(), "should parse mpadded phantom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_bounded() {
|
||||
let mut n = parse_linear("x");
|
||||
|
||||
@@ -344,7 +344,12 @@ impl Writer {
|
||||
flush(&mut body, &mut spaces, prev_space);
|
||||
prev_space = false;
|
||||
match c {
|
||||
'\t' => body.push_str("<text:tab/>"),
|
||||
'\t' => {
|
||||
// The reader collapses a literal space after a tab, so spaces
|
||||
// that follow are written as `<text:s/>`.
|
||||
body.push_str("<text:tab/>");
|
||||
prev_space = true;
|
||||
}
|
||||
'\n' => {
|
||||
body.push_str("<text:line-break/>");
|
||||
prev_space = true;
|
||||
@@ -2920,4 +2925,13 @@ mod tests {
|
||||
assert_eq!(marks(&back), marks(&doc));
|
||||
assert_eq!(marks(&back).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_after_a_tab_survive_a_round_trip() {
|
||||
for src in ["a\t b", "a\t b", "a\t éa", "é\t 😀a", "a\t\t b"] {
|
||||
let doc = Document::from_text(src);
|
||||
let back = import(&export(&doc).unwrap()).unwrap();
|
||||
assert_eq!(back.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), src, "{src:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod region;
|
||||
pub use region::Region;
|
||||
mod pixels;
|
||||
pub use pixels::{MAX_DECODED_BYTES, MAX_RASTER_PIXELS, decode_fits, fit_pixels, rgba_bytes, rgba_fits};
|
||||
|
||||
/// Points per inch.
|
||||
pub const PT_PER_IN: f32 = 72.0;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Pixel-budget arithmetic shared by every crate that decodes or allocates rasters: sizes are
|
||||
//! checked before anything is allocated, so a hostile header or request cannot exhaust memory.
|
||||
|
||||
/// Bytes a decoded picture may take as RGBA8 (what the `image` crate's default decode budget is).
|
||||
pub const MAX_DECODED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Most pixels one page raster may hold (400 MB of RGBA8); larger requests are scaled down.
|
||||
pub const MAX_RASTER_PIXELS: u64 = 100_000_000;
|
||||
|
||||
/// Bytes of a `w`×`h` RGBA8 raster, None on overflow.
|
||||
pub fn rgba_bytes(w: u32, h: u32) -> Option<u64> {
|
||||
(w as u64).checked_mul(h as u64)?.checked_mul(4)
|
||||
}
|
||||
|
||||
/// Whether a `w`×`h` RGBA8 raster fits `budget` bytes.
|
||||
pub fn rgba_fits(w: u32, h: u32, budget: u64) -> bool {
|
||||
w > 0 && h > 0 && rgba_bytes(w, h).is_some_and(|b| b <= budget)
|
||||
}
|
||||
|
||||
/// Whether decoding a `w`×`h` picture fits `budget` bytes at its peak: the decoder's buffer and
|
||||
/// the RGBA conversion (or its premultiplied copy) are alive together, so two RGBA buffers.
|
||||
pub fn decode_fits(w: u32, h: u32, budget: u64) -> bool {
|
||||
w > 0 && h > 0 && rgba_bytes(w, h).and_then(|b| b.checked_mul(2)).is_some_and(|b| b <= budget)
|
||||
}
|
||||
|
||||
/// `w`×`h` shrunk (aspect kept, each side at least 1) until it holds at most `max_pixels`.
|
||||
pub fn fit_pixels(w: u32, h: u32, max_pixels: u64) -> (u32, u32) {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
let max_pixels = max_pixels.max(1);
|
||||
let area = w as u64 * h as u64;
|
||||
if area <= max_pixels {
|
||||
return (w, h);
|
||||
}
|
||||
let f = (max_pixels as f64 / area as f64).sqrt();
|
||||
let nh = ((h as f64 * f) as u64).clamp(1, h as u64);
|
||||
let nw = ((w as f64 * f) as u64).clamp(1, w as u64).min(max_pixels / nh).max(1);
|
||||
let nh = nh.min(max_pixels / nw).max(1);
|
||||
(nw as u32, nh as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rgba_budget_boundary() {
|
||||
assert_eq!(rgba_bytes(16, 8), Some(512));
|
||||
assert!(rgba_fits(16, 8, 512));
|
||||
assert!(!rgba_fits(16, 8, 511));
|
||||
assert!(!rgba_fits(0, 8, 512), "empty is not a picture");
|
||||
assert!(decode_fits(16, 8, 1024));
|
||||
assert!(!decode_fits(16, 8, 1023), "decode + conversion hold two buffers");
|
||||
assert!(!decode_fits(0, 8, u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_pixels_keeps_normal_sizes_and_shrinks_huge_ones() {
|
||||
assert_eq!(fit_pixels(612, 792, MAX_RASTER_PIXELS), (612, 792));
|
||||
let (w, h) = fit_pixels(16_000, 16_000, 1_000_000);
|
||||
assert!(w as u64 * h as u64 <= 1_000_000 && w >= 900, "{w}x{h}");
|
||||
let (w, h) = fit_pixels(16_000, 1, 100);
|
||||
assert!(w as u64 * h as u64 <= 100 && h == 1, "{w}x{h}");
|
||||
assert_eq!(fit_pixels(0, 5, 10).0, 1);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,11 @@ named! {
|
||||
Hash = "hash",
|
||||
Underline = "underline",
|
||||
ColorOnly = "colorOnly",
|
||||
/// No mark (an ordinary deletion keeps its colour; moved-away text is plain, as in Word).
|
||||
None = "none",
|
||||
Bold = "bold",
|
||||
Italic = "italic",
|
||||
DoubleUnderline = "doubleUnderline",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +299,14 @@ pub struct MarkupOptions {
|
||||
pub changed_lines_color: Option<Rgb>,
|
||||
/// Record formatting changes while tracking.
|
||||
pub track_formatting: bool,
|
||||
/// How the place moved text was taken from is marked (Moves › Moved from).
|
||||
pub move_from_mark: DeleteMark,
|
||||
/// `None` = by author.
|
||||
pub move_from_color: Option<Rgb>,
|
||||
/// How moved text is marked where it went (Moves › Moved to).
|
||||
pub move_to_mark: InsertMark,
|
||||
/// `None` = by author.
|
||||
pub move_to_color: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl Default for MarkupOptions {
|
||||
@@ -312,14 +325,28 @@ impl Default for MarkupOptions {
|
||||
changed_lines: ChangeBar::default(),
|
||||
changed_lines_color: None,
|
||||
track_formatting: true,
|
||||
move_from_mark: DeleteMark::DoubleStrikethrough,
|
||||
move_from_color: Some(MOVE_COLOR),
|
||||
move_to_mark: InsertMark::DoubleUnderline,
|
||||
move_to_color: Some(MOVE_COLOR),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkupOptions {
|
||||
/// Tracked deletions take no room: they are hidden, or insertions and deletions aren't shown.
|
||||
pub fn hides_deletions(&self) -> bool {
|
||||
!self.insertions_deletions || self.delete_mark == DeleteMark::Hidden
|
||||
/// Whether a tracked deletion takes no room with markup shown: insertions and deletions
|
||||
/// aren't shown, or its mark is Hidden. Text a move took away (`moved_from`) follows Moved
|
||||
/// from, other deletions follow Deletions, so Deletions = Hidden leaves moved-away text shown
|
||||
/// (Word for Mac's behaviour for that combination is unverified: this is the one place to
|
||||
/// change it).
|
||||
pub fn hides_deletion(&self, moved_from: bool) -> bool {
|
||||
let mark = if moved_from { self.move_from_mark } else { self.delete_mark };
|
||||
!self.insertions_deletions || mark == DeleteMark::Hidden
|
||||
}
|
||||
|
||||
/// The deletions the layout leaves out with markup shown ([`crate::LayoutOptions::hide_marked`]).
|
||||
pub fn hidden_deletions(&self) -> crate::HideDeleted {
|
||||
crate::HideDeleted { plain: self.hides_deletion(false), moved_from: self.hides_deletion(true) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,28 +872,32 @@ fn lines(
|
||||
let rc = &st.rc;
|
||||
let m = &opts.revisions;
|
||||
// Without markup a deletion is never drawn as ordinary text, even in a layout that
|
||||
// kept it; nor when deletions are hidden.
|
||||
if rc.del.is_some() && (!opts.markup || m.hides_deletions()) {
|
||||
// kept it; nor when its mark is Hidden.
|
||||
if rc.del.is_some() && (!opts.markup || m.hides_deletion(doc.move_name(rc.del).is_some())) {
|
||||
continue;
|
||||
}
|
||||
let shown = opts.markup && m.insertions_deletions;
|
||||
let del = rc.del.filter(|_| shown);
|
||||
let ins = rc.ins.filter(|_| shown && del.is_none());
|
||||
let author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0));
|
||||
// Moved text: green, double strikethrough where it was, double underline where it went.
|
||||
// Moved text: the Moves marks and colours (by default green, double strikethrough
|
||||
// where it was, double underline where it went).
|
||||
let moved_to = doc.move_name(ins).is_some();
|
||||
let moved_from = del.is_some() && doc.move_name(del).is_some();
|
||||
// Moved text marked (none) is plain text, its colour setting aside (Word for Mac).
|
||||
let plain_move = (moved_from && m.move_from_mark == DeleteMark::None) || (moved_to && m.move_to_mark == InsertMark::None);
|
||||
let color = match (ins, del) {
|
||||
(_, Some(_)) if moved_from => MOVE_COLOR,
|
||||
(Some(_), _) if moved_to => MOVE_COLOR,
|
||||
_ if plain_move => text_color(&rc.color, rc.shading.or(rc.highlight)),
|
||||
(_, Some(r)) if moved_from => m.move_from_color.unwrap_or_else(|| author(r)),
|
||||
(Some(r), _) if moved_to => m.move_to_color.unwrap_or_else(|| author(r)),
|
||||
(Some(r), _) => m.insert_color.unwrap_or_else(|| author(r)),
|
||||
(None, Some(r)) => m.delete_color.unwrap_or_else(|| author(r)),
|
||||
_ => ink(&rc.color, rc.shading.or(rc.highlight)),
|
||||
};
|
||||
// The highlight or shading drawn under this run (as filled in the backgrounds pass).
|
||||
let text_bg = rc.highlight.or(rc.shading);
|
||||
let ins_mark = ins.map(|_| if moved_to { InsertMark::DoubleUnderline } else { m.insert_mark });
|
||||
let del_mark = del.map(|_| if moved_from { DeleteMark::DoubleStrikethrough } else { m.delete_mark });
|
||||
let ins_mark = ins.map(|_| if moved_to { m.move_to_mark } else { m.insert_mark });
|
||||
let del_mark = del.map(|_| if moved_from { m.move_from_mark } else { m.delete_mark });
|
||||
if del_mark == Some(DeleteMark::Caret) {
|
||||
// A caret where the text was, instead of the text.
|
||||
if let Some(cx) = line.cl_left(run_start).map(|c| x + c) {
|
||||
@@ -904,8 +935,8 @@ fn lines(
|
||||
g.0 = gid;
|
||||
}
|
||||
}
|
||||
let synth_bold = st.synth_bold || ins_mark == Some(InsertMark::Bold);
|
||||
let synth_italic = st.synth_italic || ins_mark == Some(InsertMark::Italic);
|
||||
let synth_bold = st.synth_bold || ins_mark == Some(InsertMark::Bold) || del_mark == Some(DeleteMark::Bold);
|
||||
let synth_italic = st.synth_italic || ins_mark == Some(InsertMark::Italic) || del_mark == Some(DeleteMark::Italic);
|
||||
// Text effects draw the letters from their outlines; the glyphs stay, invisible, for
|
||||
// the PDF's text. Not for colour glyphs (no outlines), top-to-bottom text with upright
|
||||
// characters (their outlines would lie on their side), `#` deletion marks, or WordArt
|
||||
@@ -953,7 +984,7 @@ fn lines(
|
||||
let thick = (st.size / 18.0).max(0.5);
|
||||
let underline = match (ins_mark, del_mark) {
|
||||
(Some(InsertMark::Underline), _) | (_, Some(DeleteMark::Underline)) => Underline::Single,
|
||||
(Some(InsertMark::DoubleUnderline), _) => Underline::Double,
|
||||
(Some(InsertMark::DoubleUnderline), _) | (_, Some(DeleteMark::DoubleUnderline)) => Underline::Double,
|
||||
_ => rc.underline,
|
||||
};
|
||||
let double = rc.double_strike || del_mark == Some(DeleteMark::DoubleStrikethrough);
|
||||
@@ -1154,7 +1185,10 @@ fn lines(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if line.end == LineEnd::Para && marks.paragraphs {
|
||||
// A deleted paragraph mark that isn't shown (no markup, or its mark is Hidden) draws no ¶.
|
||||
let mark_gone =
|
||||
para.is_some_and(|p| p.mark.del.is_some() && (!opts.markup || opts.revisions.hides_deletion(doc.move_name(p.mark.del).is_some())));
|
||||
if line.end == LineEnd::Para && marks.paragraphs && !mark_gone {
|
||||
// The mark follows the line's visual end, past every glyph, even where the
|
||||
// logically last text runs against the paragraph's direction.
|
||||
let ex = x + line.visual_end_x();
|
||||
@@ -1163,8 +1197,10 @@ fn lines(
|
||||
let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 };
|
||||
// A tracked (inserted or deleted) paragraph mark is drawn in its author's colour.
|
||||
let m = &opts.revisions;
|
||||
let by_author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0));
|
||||
let color = para.filter(|_| opts.markup && m.insertions_deletions).and_then(|p| match (p.mark.ins, p.mark.del) {
|
||||
(ins, del) if doc.move_name(del).is_some() || (del.is_none() && doc.move_name(ins).is_some()) => Some(MOVE_COLOR),
|
||||
(_, Some(r)) if doc.move_name(Some(r)).is_some() => Some(m.move_from_color.unwrap_or_else(|| by_author(r))),
|
||||
(Some(r), None) if doc.move_name(Some(r)).is_some() => Some(m.move_to_color.unwrap_or_else(|| by_author(r))),
|
||||
(_, Some(r)) => Some(
|
||||
m.delete_color
|
||||
.unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))),
|
||||
|
||||
+42
-11
@@ -149,6 +149,10 @@ pub struct LayoutOptions {
|
||||
pub show_hidden: bool,
|
||||
/// Final text ("No Markup"): tracked deletions take no space and draw nothing.
|
||||
pub hide_deleted: bool,
|
||||
/// Tracked deletions that take no space and draw nothing although markup is shown: those
|
||||
/// Track Changes Options mark Hidden (deletions, or moved-away text). `hide_deleted` and
|
||||
/// `deletions_in_balloons` leave every deletion out whatever this says.
|
||||
pub hide_marked: HideDeleted,
|
||||
/// Show revisions in balloons: tracked deletions in the body take no space and draw nothing
|
||||
/// (the editor shows them in balloons beside the page). Headers, footers, notes and text
|
||||
/// boxes get no balloons, so their deletions stay inline.
|
||||
@@ -186,9 +190,29 @@ impl Default for ProofOptions {
|
||||
}
|
||||
|
||||
impl LayoutOptions {
|
||||
/// Whether tracked deletions in `story` take no space and draw nothing.
|
||||
pub fn hides_deleted(&self, story: StoryRef) -> bool {
|
||||
self.hide_deleted || (self.deletions_in_balloons && story == StoryRef::Body)
|
||||
/// Which tracked deletions in `story` take no space and draw nothing.
|
||||
pub fn hides_deleted(&self, story: StoryRef) -> HideDeleted {
|
||||
if self.hide_deleted || (self.deletions_in_balloons && story == StoryRef::Body) { HideDeleted::ALL } else { self.hide_marked }
|
||||
}
|
||||
}
|
||||
|
||||
/// Which tracked deletions are left out of the layout: ordinary ones, and the text a tracked
|
||||
/// move took away (the source of a move, `w:moveFrom`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct HideDeleted {
|
||||
pub plain: bool,
|
||||
pub moved_from: bool,
|
||||
}
|
||||
|
||||
impl HideDeleted {
|
||||
pub const ALL: HideDeleted = HideDeleted { plain: true, moved_from: true };
|
||||
/// Whether any deletion is left out.
|
||||
pub fn any(self) -> bool {
|
||||
self.plain || self.moved_from
|
||||
}
|
||||
/// Whether text deleted by revision `del` (`None`: not deleted) is left out.
|
||||
pub fn hides(self, doc: &Document, del: Option<u32>) -> bool {
|
||||
del.is_some() && if doc.move_name(del).is_some() { self.moved_from } else { self.plain }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +499,7 @@ struct Key {
|
||||
read_spacing: Option<u32>,
|
||||
grid: (u32, u32, u32, bool),
|
||||
fit: bool,
|
||||
hide_deleted: bool,
|
||||
hide_deleted: HideDeleted,
|
||||
/// STYLEREF results in headers and footers ([`FieldCtx::styleref_key`]).
|
||||
sref: u64,
|
||||
}
|
||||
@@ -545,6 +569,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
|
||||
s,
|
||||
opts.show_hidden,
|
||||
opts.hide_deleted,
|
||||
opts.hide_marked,
|
||||
opts.deletions_in_balloons,
|
||||
opts.proofing,
|
||||
opts.numeral,
|
||||
@@ -644,7 +669,7 @@ struct Ctx<'a> {
|
||||
grid: para::Grid,
|
||||
/// Whether tracked deletions are left out in the story being laid out
|
||||
/// ([`LayoutOptions::hides_deleted`]): the body's, or the story of the [`layout_box`] at work.
|
||||
hide_deleted: bool,
|
||||
hide_deleted: HideDeleted,
|
||||
/// Whether body text (or its notes) laid out so far shows a page total (`NUMPAGES`,
|
||||
/// `SECTIONPAGES`), which is only known once the whole body is paginated.
|
||||
uses_totals: bool,
|
||||
@@ -701,7 +726,7 @@ impl Ctx<'_> {
|
||||
fields: &self.fields,
|
||||
show_hidden: false,
|
||||
field_codes: false,
|
||||
hide_deleted: false,
|
||||
hide_deleted: Default::default(),
|
||||
table: None,
|
||||
proofing: None,
|
||||
numeral: self.opts.numeral,
|
||||
@@ -868,14 +893,20 @@ impl Ctx<'_> {
|
||||
/// (`Float::hidden`, whatever is shown). `table_chr` is the
|
||||
/// cell's table-style character formatting ([`para::CellText::chr`]), resolved as the paragraph is
|
||||
/// laid out with (`StyleSheet::resolve_char_in`).
|
||||
fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>, show_hidden: bool, hide_deleted: bool) -> impl Fn(usize) -> bool {
|
||||
fn left_out_objects(
|
||||
doc: &Document,
|
||||
p: &Paragraph,
|
||||
table_chr: Option<&CharProps>,
|
||||
show_hidden: bool,
|
||||
hide_deleted: HideDeleted,
|
||||
) -> impl Fn(usize) -> bool {
|
||||
let mut left = Vec::new();
|
||||
// No text left out with hidden text shown and markup on (hidden drawings still are, below).
|
||||
if (hide_deleted || !show_hidden) && !p.objects.is_empty() {
|
||||
if (hide_deleted.any() || !show_hidden) && !p.objects.is_empty() {
|
||||
let style = p.props.style.as_deref();
|
||||
let is_left = |c: &CharProps| {
|
||||
let rc = doc.styles.resolve_char_in(style, table_chr, c);
|
||||
para::left_out(&rc, show_hidden, hide_deleted)
|
||||
para::left_out(doc, &rc, show_hidden, hide_deleted)
|
||||
};
|
||||
// Runs and objects are both in order: one walk finds each object's run (past the last run,
|
||||
// the paragraph mark, as `props_of_char` gives), resolving a run's formatting only once.
|
||||
@@ -906,7 +937,7 @@ fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>
|
||||
/// section or (footnotes) on each page. `pages` gives the page each footnote was placed on by an
|
||||
/// earlier pass (empty: numbered on). With `hide_deleted`, notes whose reference mark is a
|
||||
/// tracked deletion are left out, as in the final text.
|
||||
fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap<u32, usize>) -> HashMap<u32, fields::NoteNum> {
|
||||
fn note_info(doc: &Document, hide_deleted: HideDeleted, pages: &HashMap<u32, usize>) -> HashMap<u32, fields::NoteNum> {
|
||||
use wordcraft_doc::section::NoteRestart;
|
||||
let sections = doc.sections();
|
||||
let opts: Vec<[wordcraft_doc::section::NoteOptions; 2]> =
|
||||
@@ -962,7 +993,7 @@ fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap<u32, usize>) ->
|
||||
|
||||
/// Note part ids → numbers, numbered on (see [`note_info`]).
|
||||
#[cfg(test)]
|
||||
fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap<u32, u32> {
|
||||
fn note_numbers(doc: &Document, hide_deleted: HideDeleted) -> HashMap<u32, u32> {
|
||||
note_info(doc, hide_deleted, &HashMap::new()).into_iter().map(|(id, n)| (id, n.n)).collect()
|
||||
}
|
||||
|
||||
|
||||
+85
-21
@@ -405,8 +405,9 @@ pub struct ParaEnv<'a> {
|
||||
pub show_hidden: bool,
|
||||
/// Show field codes instead of results ([`crate::LayoutOptions::field_codes`]).
|
||||
pub field_codes: bool,
|
||||
/// Leave tracked deletions out, like hidden text (the final, "No Markup" text).
|
||||
pub hide_deleted: bool,
|
||||
/// Which tracked deletions to leave out, like hidden text (all of them in the final, "No
|
||||
/// Markup" text).
|
||||
pub hide_deleted: crate::HideDeleted,
|
||||
/// The table style's formatting for text in this paragraph's cell (`None` outside tables).
|
||||
pub table: Option<&'a CellText>,
|
||||
/// Check spelling (and grammar, as the options say) for squiggles; `None` = no squiggles.
|
||||
@@ -633,6 +634,27 @@ pub(crate) fn remap_range(map_starts: &[usize], orig_of: &[usize], orig_len: usi
|
||||
(s.min(e), s.max(e))
|
||||
}
|
||||
|
||||
/// The bidi levels a piece of text is shaped by.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Dir<'l> {
|
||||
/// Paragraph text: the paragraph's levels at the piece's offset.
|
||||
Para,
|
||||
/// Text laid out on its own (a field result): these levels, by byte of the text.
|
||||
Own(&'l [u8]),
|
||||
/// Each strong run in its own direction (list labels, ruby, left-to-right results).
|
||||
Guess,
|
||||
}
|
||||
|
||||
/// The direction of `c` if it is a strong letter (UAX #9 P2): `Some(true)` right to left.
|
||||
fn strong_rtl(c: char) -> Option<bool> {
|
||||
use unicode_bidi::BidiClass::{AL, L, R};
|
||||
match unicode_bidi::bidi_class(c) {
|
||||
R | AL => Some(true),
|
||||
L => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bidi embedding levels per byte of `text` (UAX #9 rules up to I2), or empty when the
|
||||
/// paragraph is left to right and has no right-to-left characters.
|
||||
pub fn bidi_levels(text: &str, rtl_para: bool) -> Vec<u8> {
|
||||
@@ -791,12 +813,11 @@ impl<'a> Builder<'a> {
|
||||
|
||||
/// Shape `text` (a piece of the paragraph starting at byte `base`) in one style and append clusters.
|
||||
fn shape(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>) {
|
||||
self.shape_with(text, base, rc, kind_override, true);
|
||||
self.shape_with(text, base, rc, kind_override, Dir::Para);
|
||||
}
|
||||
|
||||
/// `para_text`: `text` is paragraph text at `base`, so the paragraph's bidi levels apply
|
||||
/// (false for field results and list labels, which are shaped on their own).
|
||||
fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, para_text: bool) {
|
||||
/// `dir`: the bidi levels `text` is shaped by (see [`Dir`]).
|
||||
fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, dir: Dir<'_>) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -818,8 +839,14 @@ impl<'a> Builder<'a> {
|
||||
.map(|f| wordcraft_fonts::word::resolve(f, rc.bold, rc.italic))
|
||||
.filter(|r| !r.substituted)
|
||||
.map(|r| r.face);
|
||||
let use_levels = para_text && !self.levels.is_empty();
|
||||
let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() };
|
||||
// The levels by byte of `text`, from `lbase` on.
|
||||
let (levels, lbase): (&[u8], usize) = match dir {
|
||||
Dir::Para => (&self.levels, base),
|
||||
Dir::Own(l) => (l, 0),
|
||||
Dir::Guess => (&[], 0),
|
||||
};
|
||||
let use_levels = !levels.is_empty();
|
||||
let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, levels, lbase) } else { Vec::new() };
|
||||
// A missing Thai font: one face for the run's Thai and Latin text (#1549). A run with its
|
||||
// own complex-script font keeps that font for the Thai letters.
|
||||
let run_face: Option<FaceRef> = if primary_cs.is_none_or(|f| f.id() == primary.id()) {
|
||||
@@ -848,7 +875,7 @@ impl<'a> Builder<'a> {
|
||||
let mut cur: Option<SegKey> = None;
|
||||
let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
|
||||
let rtl = use_levels && levels.get(lbase + i).is_some_and(|l| l % 2 == 1);
|
||||
// A symbol-font character becomes its Unicode symbol (#776).
|
||||
let c = show(c);
|
||||
// Displayed form (Word › Numeral): one char in, one char out. Coverage and the
|
||||
@@ -1002,8 +1029,14 @@ impl<'a> Builder<'a> {
|
||||
|
||||
/// One cluster for a whole object with its display text (fields, note references).
|
||||
fn shape_atomic(&mut self, text: &str, start: usize, end: usize, rc: &Arc<ResolvedChar>) {
|
||||
// Right-to-left text in the result reads like ordinary text (UAX #9, #1670): the result is
|
||||
// an isolate whose direction is its first strong letter's (else the text's around the
|
||||
// field), and each level run is shaped (mirrored, joined) and placed in that direction.
|
||||
let around = self.levels.get(start).is_some_and(|l| l % 2 == 1);
|
||||
let rtl_base = text.chars().find_map(strong_rtl).unwrap_or(around);
|
||||
let lv = bidi_levels(text, rtl_base);
|
||||
let before = self.clusters.len();
|
||||
self.shape_with(text, start, rc, None, false);
|
||||
self.shape_with(text, start, rc, None, if lv.is_empty() { Dir::Guess } else { Dir::Own(&lv) });
|
||||
let added: Vec<Cluster> = self.clusters.drain(before..).collect();
|
||||
let Some(first) = added.first() else {
|
||||
let si = self.style(rc, None, false);
|
||||
@@ -1023,15 +1056,23 @@ impl<'a> Builder<'a> {
|
||||
});
|
||||
return;
|
||||
};
|
||||
// Merge into one cluster: rebase glyph dx onto the first cluster, and keep each glyph's
|
||||
// text (a ligature's glyph takes the letters of the clusters after it that have none).
|
||||
let order = if lv.is_empty() {
|
||||
(0..added.len()).collect()
|
||||
} else {
|
||||
let cl_lv: Vec<u8> = added.iter().map(|c| lv.get(c.start.saturating_sub(start)).copied().unwrap_or(u8::from(rtl_base))).collect();
|
||||
visual_order(&cl_lv)
|
||||
};
|
||||
// Merge into one cluster: rebase glyph dx onto the first cluster drawn, and keep each
|
||||
// glyph's text (a ligature's glyph takes the letters of the clusters after it that have none).
|
||||
let mut x = 0.0;
|
||||
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
for c in &added {
|
||||
for c in order.iter().filter_map(|&i| added.get(i)) {
|
||||
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
g.dx += x;
|
||||
}
|
||||
x += c.adv;
|
||||
}
|
||||
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
for c in &added {
|
||||
let (a, b) = (c.start.saturating_sub(start), c.end.saturating_sub(start));
|
||||
if c.g1 <= c.g0 {
|
||||
let from = ranges.last().map(|r| r.start);
|
||||
@@ -1206,7 +1247,7 @@ impl Builder<'_> {
|
||||
self.shape_atomic(base, start, end, rc);
|
||||
let ci = self.clusters.len().checked_sub(1)?;
|
||||
let (bc, bg) = (self.clusters.len(), self.glyphs.len());
|
||||
self.shape_with(ruby, 0, ruby_rc, None, false);
|
||||
self.shape_with(ruby, 0, ruby_rc, None, Dir::Guess);
|
||||
let cl: Vec<Cluster> = self.clusters.drain(bc..).collect();
|
||||
let ruby_w: f32 = cl.iter().map(|c| c.adv).sum();
|
||||
let base_w = self.clusters.get(ci).map_or(0.0, |c| c.adv);
|
||||
@@ -1249,8 +1290,8 @@ impl Builder<'_> {
|
||||
|
||||
/// Whether text with this formatting is left out of the layout: hidden text unless it is shown, and
|
||||
/// tracked deletions in the final text. It takes no room, draws nothing and is no place to break.
|
||||
pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool) -> bool {
|
||||
(rc.hidden && !show_hidden) || (rc.del.is_some() && hide_deleted)
|
||||
pub(crate) fn left_out(doc: &Document, rc: &ResolvedChar, show_hidden: bool, hide_deleted: crate::HideDeleted) -> bool {
|
||||
(rc.hidden && !show_hidden) || hide_deleted.hides(doc, rc.del)
|
||||
}
|
||||
|
||||
/// Lay out one paragraph.
|
||||
@@ -1319,7 +1360,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let rc = resolve(props);
|
||||
runs.push(ProofRun::of(range.clone(), &rc));
|
||||
let Some(text) = p.text.get(range.clone()) else { continue };
|
||||
if left_out(&rc, b.env.show_hidden, b.env.hide_deleted) {
|
||||
if left_out(doc, &rc, b.env.show_hidden, b.env.hide_deleted) {
|
||||
match left.last_mut() {
|
||||
Some(last) if last.end == range.start => last.end = range.end,
|
||||
_ => left.push(range.clone()),
|
||||
@@ -1328,7 +1369,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let g = b.glyphs.len() as u32;
|
||||
// Word still prints the note of a hidden reference mark (and numbers it); a deleted
|
||||
// reference is gone from the final text, note and all.
|
||||
let keeps_notes = !(rc.del.is_some() && b.env.hide_deleted);
|
||||
let keeps_notes = !b.env.hide_deleted.hides(doc, rc.del);
|
||||
for (i, c) in text.char_indices() {
|
||||
let mut note = None;
|
||||
if c == OBJ {
|
||||
@@ -1588,7 +1629,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let shown = |t: &str| -> String { wordcraft_doc::symbol_fonts::map_text(&rc.font, t) };
|
||||
let before_c = b.clusters.len();
|
||||
let before_g = b.glyphs.len();
|
||||
b.shape_with(text, 0, &rc, None, false);
|
||||
b.shape_with(text, 0, &rc, None, Dir::Guess);
|
||||
let mut cl: Vec<Cluster> = b.clusters.drain(before_c..).collect();
|
||||
// A right-to-left paragraph's label reads right to left too ("1." shows as ".1").
|
||||
if rp.bidi {
|
||||
@@ -1652,6 +1693,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
};
|
||||
let auto = env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens;
|
||||
pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps, &runs);
|
||||
// A paragraph whose text and mark are all a deletion the layout leaves out (a deleted
|
||||
// paragraph in the final text, or moved-away text marked Hidden) is gone: it takes no room.
|
||||
let gone = env.hide_deleted.hides(doc, mark_rc.del) && (p.text.is_empty() || left.first() == Some(&(0..p.text.len())));
|
||||
pl.left_out = left;
|
||||
for k in pl.hyph_after.clone() {
|
||||
if let Some(c) = pl.clusters.get_mut(k as usize) {
|
||||
@@ -1659,9 +1703,29 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
}
|
||||
}
|
||||
break_lines(&mut pl, env, mark_style, env.label.as_ref().map(|(_, l)| l));
|
||||
if gone {
|
||||
collapse(&mut pl);
|
||||
}
|
||||
pl
|
||||
}
|
||||
|
||||
/// Lays a paragraph out as nothing: no height, no spacing, no label, its lines (kept for the
|
||||
/// caret and hit testing) all at its top with no height.
|
||||
fn collapse(pl: &mut ParaLayout) {
|
||||
for l in &mut pl.lines {
|
||||
l.top = 0.0;
|
||||
l.height = 0.0;
|
||||
l.baseline = 0.0;
|
||||
}
|
||||
pl.height = 0.0;
|
||||
pl.rp.space_before = 0.0;
|
||||
pl.rp.space_after = 0.0;
|
||||
pl.label = None;
|
||||
// Its shading and borders would be drawn across the page at no height.
|
||||
pl.rp.shading = None;
|
||||
pl.rp.borders = None;
|
||||
}
|
||||
|
||||
/// Next tab stop after `x` (column coordinates): explicit stops, the hanging-indent implicit stop,
|
||||
/// then default stops.
|
||||
fn next_tab(x: f32, tabs: &[TabStop], default_tab: f32, hanging_at: Option<f32>) -> TabStop {
|
||||
@@ -2805,7 +2869,7 @@ mod label_spacing_tests {
|
||||
width: 100.0,
|
||||
label: Some(("•".into(), level.clone())),
|
||||
show_hidden: false,
|
||||
hide_deleted: false,
|
||||
hide_deleted: Default::default(),
|
||||
table: None,
|
||||
proofing: None,
|
||||
field_codes: false,
|
||||
|
||||
@@ -193,7 +193,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f
|
||||
fields: &fields,
|
||||
show_hidden: false,
|
||||
field_codes: false,
|
||||
hide_deleted: false,
|
||||
hide_deleted: Default::default(),
|
||||
table: text.as_ref(),
|
||||
proofing: None,
|
||||
// Measurement uses a fixed environment (like show_hidden/proofing above).
|
||||
|
||||
@@ -3246,7 +3246,7 @@ fn footnotes_inside_text_boxes_are_numbered_and_placed() {
|
||||
let b = l.caret(&d.start_of(StoryRef::Part(after))).unwrap();
|
||||
assert_eq!((a.page, b.page), (0, 0));
|
||||
assert!(a.top > 600.0 && a.top < b.top, "{a:?} {b:?}");
|
||||
let nums = note_numbers(&d, false);
|
||||
let nums = note_numbers(&d, HideDeleted::default());
|
||||
assert_eq!((nums.get(&in_box), nums.get(&after)), (Some(&1), Some(&2)));
|
||||
}
|
||||
|
||||
@@ -4207,9 +4207,9 @@ fn objects_left_out_through_the_table_style_formatting() {
|
||||
p.insert_object(1, rect_shape(10.0, 10.0, Default::default()), &Default::default()).unwrap();
|
||||
let d = Document::from_text("");
|
||||
let hidden = wordcraft_doc::CharProps { hidden: Some(true), ..Default::default() };
|
||||
assert!(!left_out_objects(&d, &p, None, false, false)(0));
|
||||
assert!(left_out_objects(&d, &p, Some(&hidden), false, false)(0));
|
||||
assert!(!left_out_objects(&d, &p, Some(&hidden), true, false)(0), "hidden text shown");
|
||||
assert!(!left_out_objects(&d, &p, None, false, HideDeleted::default())(0));
|
||||
assert!(left_out_objects(&d, &p, Some(&hidden), false, HideDeleted::default())(0));
|
||||
assert!(!left_out_objects(&d, &p, Some(&hidden), true, HideDeleted::default())(0), "hidden text shown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5530,7 +5530,7 @@ fn footnotes_restart_on_each_page_and_section() {
|
||||
d.settings.footnote_pr = NoteProps::default();
|
||||
d.para_mut(StoryRef::Body, &Path::top(49)).unwrap().section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.footnote_pr = NoteProps { num_restart: Some(NoteRestart::EachSect), num_start: Some(5), ..Default::default() };
|
||||
let nums = note_numbers(&d, false);
|
||||
let nums = note_numbers(&d, HideDeleted::default());
|
||||
assert_eq!((nums[&a], nums[&b], nums[&c]), (1, 2, 5));
|
||||
}
|
||||
|
||||
@@ -7381,6 +7381,46 @@ fn justified_multi_column_pages_stay_at_the_top() {
|
||||
assert_eq!(last(PageVAlign::Justify), last(PageVAlign::Top));
|
||||
}
|
||||
|
||||
/// Gap #5 review: a paragraph deleted whole (text and mark) takes no room in the final text, and
|
||||
/// neither does moved-away text marked Hidden; the paragraph after it sits where it would without
|
||||
/// it, and a deletion that isn't left out keeps its line.
|
||||
#[test]
|
||||
fn a_paragraph_left_out_whole_takes_no_room() {
|
||||
let mut d = Document::from_text("One\nGone\nThree");
|
||||
d.format_range(&Pos::body(1, 0), &Pos::body(2, 0), &|c| c.del = Some(0)).unwrap();
|
||||
let three_top =
|
||||
|d: &Document, opts: &LayoutOptions, block: usize| layout(d, &mut LayoutCache::new(), opts).caret(&Pos::body(block, 0)).unwrap().top;
|
||||
let without = three_top(&Document::from_text("One\nThree"), &LayoutOptions::default(), 1);
|
||||
let fin = LayoutOptions { hide_deleted: true, ..Default::default() };
|
||||
assert!((three_top(&d, &fin, 2) - without).abs() < 0.01, "no blank line in the final text");
|
||||
assert!(three_top(&d, &LayoutOptions::default(), 2) > without + 1.0, "with markup it keeps its line");
|
||||
// Only moved-away text hidden: an ordinary deletion keeps its line.
|
||||
let moves = LayoutOptions { hide_marked: HideDeleted { plain: false, moved_from: true }, ..Default::default() };
|
||||
assert!(three_top(&d, &moves, 2) > without + 1.0);
|
||||
let plain = LayoutOptions { hide_marked: HideDeleted { plain: true, moved_from: false }, ..Default::default() };
|
||||
assert!((three_top(&d, &plain, 2) - without).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Gap #5 review: a paragraph left out whole draws no shading and no borders (they would be
|
||||
/// lines across the page at its zero height).
|
||||
#[test]
|
||||
fn a_paragraph_left_out_whole_draws_no_borders_or_shading() {
|
||||
let mut d = Document::from_text("One\nGone\nThree");
|
||||
let line = Some(Border::single(1.0));
|
||||
d.format_paragraphs(&Pos::body(1, 0), &Pos::body(1, 0), &|p| {
|
||||
p.borders = Some(Borders { top: line, left: line, bottom: line, right: line, ..Borders::default() });
|
||||
p.shading = Some(wordcraft_doc::Rgb(0xEE, 0xEE, 0xEE));
|
||||
})
|
||||
.unwrap();
|
||||
d.format_range(&Pos::body(1, 0), &Pos::body(2, 0), &|c| c.del = Some(0)).unwrap();
|
||||
let decorations = |hide_deleted: bool| {
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions { hide_deleted, ..Default::default() });
|
||||
l.pages[0].items.iter().filter(|i| matches!(i, Placed::Fill { .. } | Placed::Rule { .. })).count()
|
||||
};
|
||||
assert_eq!(decorations(false), 5, "shading and four borders with markup");
|
||||
assert_eq!(decorations(true), 0, "none in the final text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_borders_stay_on_row() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
|
||||
@@ -175,3 +175,115 @@ fn body_edits_keep_styleref_headers_cached() {
|
||||
assert_eq!(shown(&edited.pages[2].header), ["The Middles", "above"]);
|
||||
assert_eq!(shown(&edited.pages[0].header), ["Beginnings", "below"]);
|
||||
}
|
||||
|
||||
/// The x of each shown character's first glyph in a header field result, in text order.
|
||||
fn field_char_xs(d: &Document) -> Vec<(String, Vec<f32>)> {
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(d, &mut c, &LayoutOptions::default());
|
||||
let mut out = Vec::new();
|
||||
for it in &l.pages[0].header {
|
||||
let Placed::Lines { para, .. } = it else { continue };
|
||||
for (k, text, ranges) in ¶.shown {
|
||||
let cl = ¶.clusters[*k];
|
||||
let glyphs = ¶.glyphs[cl.g0 as usize..cl.g1 as usize];
|
||||
let xs = text
|
||||
.char_indices()
|
||||
.map(|(i, _)| glyphs.iter().zip(ranges).find(|(_, r)| r.start == i).map(|(g, _)| g.dx).unwrap_or(f32::NAN))
|
||||
.collect();
|
||||
out.push((text.clone(), xs));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A STYLEREF showing a Hebrew heading reads right to left in the header like ordinary text
|
||||
/// (#1670): "פרק א" draws פ rightmost, not reversed as "א קרפ". Latin results keep their order,
|
||||
/// also in a right-to-left header paragraph.
|
||||
#[test]
|
||||
fn hebrew_styleref_result_reads_right_to_left() {
|
||||
let d = doc(&[("Heading1", "פרק א", false), ("Normal", "Text.", false)], &[r#"STYLEREF "Heading 1""#]);
|
||||
let got = field_char_xs(&d);
|
||||
let (text, xs) = &got[0];
|
||||
assert_eq!(text, "פרק א");
|
||||
assert!(xs.iter().all(|x| x.is_finite()), "every letter has a glyph: {xs:?}");
|
||||
assert!(xs.windows(2).all(|w| w[0] > w[1]), "letters in text order run right to left: {xs:?}");
|
||||
// A Latin heading in a right-to-left header paragraph still reads left to right.
|
||||
let mut d = doc(&[("Heading1", "Part one", false)], &[r#"STYLEREF "Heading 1""#]);
|
||||
let hdr = d.last_section.headers.default.unwrap();
|
||||
d.para_mut(StoryRef::Part(hdr), &Path::top(0)).unwrap().props.bidi = Some(true);
|
||||
let got = field_char_xs(&d);
|
||||
let (text, xs) = &got[0];
|
||||
assert_eq!(text, "Part one");
|
||||
assert!(xs.windows(2).all(|w| w[0] < w[1]), "Latin letters run left to right: {xs:?}");
|
||||
}
|
||||
|
||||
/// What each header field result draws, read left to right: each glyph's character, a bracket
|
||||
/// read as the bracket its glyph shows (a mirrored `(` reads `)`).
|
||||
fn field_drawn(d: &Document, opts: &LayoutOptions) -> Vec<String> {
|
||||
let mirror = |c: char| match c {
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
c => c,
|
||||
};
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(d, &mut c, opts);
|
||||
let mut out = Vec::new();
|
||||
for it in &l.pages[0].header {
|
||||
let Placed::Lines { para, .. } = it else { continue };
|
||||
for (k, text, ranges) in ¶.shown {
|
||||
let cl = ¶.clusters[*k];
|
||||
// A fallback face's glyphs share the cluster, so a glyph is matched in every face.
|
||||
let gids = |ch: char| -> Vec<Option<u32>> {
|
||||
para.styles.iter().map(|st| wordcraft_fonts::shape_run(&st.face, &ch.to_string(), &[], |c| c, false).first().map(|g| g.gid)).collect()
|
||||
};
|
||||
let glyphs = ¶.glyphs[cl.g0 as usize..cl.g1 as usize];
|
||||
let mut drawn: Vec<(f32, char)> = glyphs
|
||||
.iter()
|
||||
.zip(ranges)
|
||||
.map(|(g, r)| {
|
||||
let ch = text[r.start..].chars().next().unwrap();
|
||||
let m = mirror(ch);
|
||||
let is = |ch: char| gids(ch).contains(&Some(g.gid));
|
||||
let shown = if m != ch && is(m) && !is(ch) { m } else { ch };
|
||||
(g.dx, shown)
|
||||
})
|
||||
.collect();
|
||||
drawn.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
out.push(drawn.into_iter().map(|d| d.1).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rtl_header(d: &mut Document) {
|
||||
let hdr = d.last_section.headers.default.unwrap();
|
||||
d.para_mut(StoryRef::Part(hdr), &Path::top(0)).unwrap().props.bidi = Some(true);
|
||||
}
|
||||
|
||||
/// Brackets in a field result are mirrored and placed by the same bidi run (#1670 review): a
|
||||
/// field code or a Latin result in a right-to-left header keeps its brackets as written, and a
|
||||
/// Hebrew word in brackets in a left-to-right result draws its brackets the right way round.
|
||||
#[test]
|
||||
fn field_results_mirror_brackets_with_their_bidi_runs() {
|
||||
let mut got = Vec::new();
|
||||
// The field-code view of a PAGE field in a right-to-left header.
|
||||
let mut d = doc(&[("Normal", "Text.", false)], &["PAGE"]);
|
||||
rtl_header(&mut d);
|
||||
got.extend(field_drawn(&d, &LayoutOptions { field_codes: true, ..LayoutOptions::default() }));
|
||||
// A Latin result with brackets, and digits, in a right-to-left header.
|
||||
for heading in ["(Chapter 1)", "12"] {
|
||||
let mut d = doc(&[("Heading1", heading, false)], &[r#"STYLEREF "Heading 1""#]);
|
||||
rtl_header(&mut d);
|
||||
got.extend(field_drawn(&d, &LayoutOptions::default()));
|
||||
}
|
||||
// A Hebrew word in brackets at the end of a left-to-right result, and the Hebrew heading.
|
||||
for heading in ["Part 1 (פרק)", "פרק א"] {
|
||||
let d = doc(&[("Heading1", heading, false)], &[r#"STYLEREF "Heading 1""#]);
|
||||
got.extend(field_drawn(&d, &LayoutOptions::default()));
|
||||
}
|
||||
assert_eq!(got, ["{ PAGE }", "(Chapter 1)", "12", "Part 1 (קרפ)", "א קרפ"]);
|
||||
}
|
||||
+12
-4
@@ -1680,10 +1680,18 @@ 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> {
|
||||
load_image_within(data, wordcraft_geom::MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
/// [`load_image`] with an explicit RGBA byte budget (a small one in tests).
|
||||
fn load_image_within(data: &Arc<Vec<u8>>, budget: u64) -> Option<Image> {
|
||||
let fmt = image::guess_format(data).ok()?;
|
||||
// The side check below used to run only after a full decode; applying it as a decode limit
|
||||
// rejects a small file that declares a huge image at header time instead. The default
|
||||
// allocation cap (512 MB, what `load_from_memory_with_format` applied) stays in force.
|
||||
// Header-only size check before anything is allocated (decode buffer + RGBA conversion are
|
||||
// charged); the side limits and the default 512 MB allocation cap stay as a second guard.
|
||||
let (hw, hh) = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
if !wordcraft_geom::decode_fits(hw, hh, budget) {
|
||||
return None;
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_IMAGE_SIDE);
|
||||
@@ -1697,7 +1705,7 @@ fn load_image(data: &Arc<Vec<u8>>) -> Option<Image> {
|
||||
if fmt == image::ImageFormat::Jpeg && matches!(dec.color(), image::ColorType::L8 | image::ColorType::Rgb8) {
|
||||
return Image::from_jpeg(data.clone().into(), true).ok();
|
||||
}
|
||||
Some(Image::from_rgba8(dec.to_rgba8().into_raw(), w, h))
|
||||
Some(Image::from_rgba8(dec.into_rgba8().into_raw(), w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1461,6 +1461,32 @@ fn right_to_left_clusters_are_written_whole_left_to_right() {
|
||||
assert_eq!(visual_order(text, &glyphs, &ranges, &parts), [5, 4, 2, 3, 0, 1]);
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
let mut body = kind.to_vec();
|
||||
body.extend_from_slice(data);
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for b in &body {
|
||||
crc ^= *b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out.extend_from_slice(&(!crc).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
let mut ihdr = w.to_be_bytes().to_vec();
|
||||
ihdr.extend_from_slice(&h.to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
chunk(&mut out, b"IHDR", &ihdr);
|
||||
chunk(&mut out, b"IDAT", &[0x78, 0x9c, 0, 0]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_page_index_returns_an_error() {
|
||||
let d = Document::from_text("Hostile page selection");
|
||||
@@ -1623,3 +1649,16 @@ fn zero_width_connector_dashes() {
|
||||
assert!(raw.contains("[3 2.25]0 d"), "stroke-relative dash array for {kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_budget_is_checked_from_the_header() {
|
||||
let data = Arc::new(png());
|
||||
// The decoded image and its RGBA conversion are charged: two buffers at peak.
|
||||
assert!(load_image_within(&data, 1024).is_some());
|
||||
assert!(load_image_within(&data, 1023).is_none());
|
||||
// A header (valid CRC) claiming 30000x30000 with no pixel data reads fine, then the budget refuses it.
|
||||
let hdr = ihdr_only_png(30_000, 30_000);
|
||||
let dims = image::ImageReader::new(std::io::Cursor::new(hdr.as_slice())).with_guessed_format().unwrap().into_dimensions().unwrap();
|
||||
assert_eq!(dims, (30_000, 30_000));
|
||||
assert!(load_image(&Arc::new(hdr)).is_none());
|
||||
}
|
||||
@@ -58,7 +58,11 @@ impl Rendered {
|
||||
/// PNG bytes (empty on failure, logged).
|
||||
pub fn to_png(&self) -> Vec<u8> {
|
||||
let mut px = self.to_straight();
|
||||
px.resize(self.width as usize * self.height as usize * 4, 0);
|
||||
let Some(len) = wordcraft_geom::rgba_bytes(self.width, self.height).and_then(|b| usize::try_from(b).ok()) else {
|
||||
log::error!("PNG encode: size {}×{} overflows", self.width, self.height);
|
||||
return Vec::new();
|
||||
};
|
||||
px.resize(len, 0);
|
||||
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else {
|
||||
log::error!("PNG encode: bad size {}×{}", self.width, self.height);
|
||||
return Vec::new();
|
||||
@@ -175,12 +179,22 @@ 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> {
|
||||
decode_pixmap_within(bytes, wordcraft_geom::MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
/// [`decode_pixmap`] with an explicit RGBA byte budget (a small one in tests).
|
||||
fn decode_pixmap_within(bytes: &[u8], budget: u64) -> Option<Pixmap> {
|
||||
// The header gives the size; refuse before the decoder (and the RGBA copy) allocate anything.
|
||||
let (hw, hh) = image_size(bytes)?;
|
||||
if !wordcraft_geom::decode_fits(hw, hh, budget) {
|
||||
return None;
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_DECODE_SIDE);
|
||||
limits.max_image_height = Some(MAX_DECODE_SIDE);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?.to_rgba8();
|
||||
let img = reader.decode().ok()?.into_rgba8();
|
||||
let (w, h) = img.dimensions();
|
||||
premul_pixmap(w, h, img.as_raw())
|
||||
}
|
||||
@@ -495,7 +509,16 @@ pub fn render_page(doc: &Document, page: &Page, scale: f32, opts: &RenderOptions
|
||||
|
||||
/// Render a page into a `w`×`h` raster with `view` (page points → pixels).
|
||||
pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions) -> Rendered {
|
||||
let (w16, h16) = (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16);
|
||||
render_region_capped(doc, page, w, h, view, opts, wordcraft_geom::MAX_RASTER_PIXELS)
|
||||
}
|
||||
|
||||
/// [`render_region`] with an explicit pixel cap (a small one in tests).
|
||||
fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions, max_pixels: u64) -> Rendered {
|
||||
let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
|
||||
let (cw, ch) = wordcraft_geom::fit_pixels(w, h, max_pixels);
|
||||
// Over the cap the whole picture is drawn smaller, not cropped.
|
||||
let view = if (cw, ch) == (w, h) { view } else { Affine::scale_non_uniform(cw as f64 / w as f64, ch as f64 / h as f64) * view };
|
||||
let (w16, h16) = (cw as u16, ch as u16);
|
||||
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: default_threads(), ..Default::default() });
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0));
|
||||
@@ -578,7 +601,9 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32,
|
||||
let fin = |v: f32, d: f32| if v.is_finite() && v >= 0.0 { v } else { d };
|
||||
let (size, w, h, margin) = (fin(size, 12.0).min(1000.0) as f64, fin(w, 1.0), fin(h, 1.0), fin(margin, 0.0).min(1000.0) as f64);
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale.min(16.0) } else { 1.0 };
|
||||
let (w16, h16) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16, ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16);
|
||||
let (pw, ph) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE), ((h * scale).ceil() as u32).clamp(1, MAX_SIDE));
|
||||
let (cw, ch) = wordcraft_geom::fit_pixels(pw, ph, wordcraft_geom::MAX_RASTER_PIXELS);
|
||||
let (w16, h16) = (cw as u16, ch as u16);
|
||||
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(Rgb(255, 255, 255), 1.0));
|
||||
@@ -586,7 +611,7 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32,
|
||||
ctx.set_paint(color(Rgb(0, 0, 0), 1.0));
|
||||
let upem = if face.upem.is_finite() && face.upem > 0.0 { face.upem } else { 1000.0 };
|
||||
let k = size / upem;
|
||||
let view = Affine::scale(scale as f64);
|
||||
let view = Affine::scale_non_uniform(cw as f64 / pw as f64, ch as f64 / ph as f64) * Affine::scale(scale as f64);
|
||||
let cap = if face.cap_height.is_finite() { face.cap_height.clamp(upem * 0.3, upem) } else { upem * 0.7 };
|
||||
let baseline = (h as f64 + cap * k) / 2.0;
|
||||
let mut x = margin;
|
||||
@@ -1117,9 +1142,12 @@ pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32,
|
||||
/// at `scale` px/pt: no paper, no watermark. A dragged object's preview.
|
||||
pub fn render_items(doc: &Document, items: &[Draw], x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let w16 = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16;
|
||||
let h16 = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16;
|
||||
let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64));
|
||||
let (pw, ph) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE), ((h * scale).ceil() as u32).clamp(1, MAX_SIDE));
|
||||
let (cw, ch) = wordcraft_geom::fit_pixels(pw, ph, wordcraft_geom::MAX_RASTER_PIXELS);
|
||||
let (w16, h16) = (cw as u16, ch as u16);
|
||||
let view = Affine::scale_non_uniform(cw as f64 / pw as f64, ch as f64 / ph as f64)
|
||||
* Affine::scale(scale as f64)
|
||||
* Affine::translate((-x as f64, -y as f64));
|
||||
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
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);
|
||||
for it in items {
|
||||
@@ -1392,6 +1420,59 @@ mod tests {
|
||||
assert_eq!(&img.pixels[0..3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
let mut body = kind.to_vec();
|
||||
body.extend_from_slice(data);
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for b in &body {
|
||||
crc ^= *b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out.extend_from_slice(&(!crc).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
let mut ihdr = w.to_be_bytes().to_vec();
|
||||
ihdr.extend_from_slice(&h.to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
chunk(&mut out, b"IHDR", &ihdr);
|
||||
chunk(&mut out, b"IDAT", &[0x78, 0x9c, 0, 0]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_budget_is_checked_from_the_header() {
|
||||
let mut buf = Vec::new();
|
||||
let img = image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([x as u8, y as u8, 9, 255]));
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
||||
// Decode, the RGBA conversion and the premultiplied copy are charged: two buffers at peak.
|
||||
assert!(decode_pixmap_within(&buf, 1024).is_some());
|
||||
assert!(decode_pixmap_within(&buf, 1023).is_none());
|
||||
// A header (valid CRC) claiming 60000x60000 with no pixel data reads fine, then the budget refuses it.
|
||||
let hdr = ihdr_only_png(60_000, 60_000);
|
||||
assert_eq!(image_size(&hdr), Some((60_000, 60_000)));
|
||||
assert!(decode_pixmap(&hdr).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_raster_is_capped_by_total_pixels() {
|
||||
let d = Document::new();
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let opts = RenderOptions::default();
|
||||
let big = render_region_capped(&d, &l.pages[0], 3000, 2000, Affine::scale(4.0), &opts, 100_000);
|
||||
assert!(big.width as u64 * big.height as u64 <= 100_000, "{}x{}", big.width, big.height);
|
||||
assert!(big.width > 300 && big.height > 200);
|
||||
assert_eq!(big.pixels.len() as u64, big.width as u64 * big.height as u64 * 4);
|
||||
let ok = render_region_capped(&d, &l.pages[0], 300, 200, Affine::IDENTITY, &opts, 100_000);
|
||||
assert_eq!((ok.width, ok.height), (300, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_scale_is_clamped() {
|
||||
let d = Document::new();
|
||||
|
||||
@@ -45,6 +45,9 @@ pub struct CanvasState {
|
||||
/// revision: finding out walks every paragraph, too slow for every frame (#811).
|
||||
markup_facts: std::cell::Cell<Option<((u64, u64), [bool; 3])>>,
|
||||
pub scroll_to_caret: bool,
|
||||
/// Set while a command runs for a mouse drag on a table: the pointer is already
|
||||
/// on the target, so the view stays put instead of following the caret (#1738).
|
||||
pub keep_view: bool,
|
||||
/// A heading navigation click aligns its page against the navigation pane, not just visible.
|
||||
pub(crate) nav_to_caret: bool,
|
||||
/// Touchpad travel not yet converted to an entire reading-column turn.
|
||||
@@ -151,6 +154,13 @@ pub struct CanvasState {
|
||||
/// The last key ran an Alt shortcut: the text its key sends (it may arrive a frame later)
|
||||
/// isn't typed. The next key press clears it.
|
||||
pub(crate) swallow_text: bool,
|
||||
/// An input method's `DeleteSurrounding` just erased text with this formatting: the text it
|
||||
/// sends next replaces it, keeps the formatting and joins the same undo step
|
||||
/// (`keys::delete_surrounding`, #1480).
|
||||
pub(crate) ime_join_text: Option<wordcraft_doc::props::CharProps>,
|
||||
/// An automatic change (AutoCorrect, AutoFormat) altered the text around the caret: the
|
||||
/// input method is asked to start afresh (#1480).
|
||||
pub(crate) ime_reset: bool,
|
||||
/// Alt+numpad entry in progress: the digits typed while Alt is held (#961).
|
||||
pub(crate) alt_code: String,
|
||||
/// The character an Alt code just typed: if the platform also sends its own composed
|
||||
@@ -191,6 +201,7 @@ impl Default for CanvasState {
|
||||
page_keys: PageKeys::default(),
|
||||
markup_facts: std::cell::Cell::new(None),
|
||||
scroll_to_caret: true,
|
||||
keep_view: false,
|
||||
nav_to_caret: false,
|
||||
read_wheel_accum: 0.0,
|
||||
read_viewport: Settle::default(),
|
||||
@@ -232,6 +243,8 @@ impl Default for CanvasState {
|
||||
mini_dismissed: None,
|
||||
pasted: false,
|
||||
swallow_text: false,
|
||||
ime_join_text: None,
|
||||
ime_reset: false,
|
||||
alt_code: String::new(),
|
||||
alt_code_echo: None,
|
||||
wheel: Default::default(),
|
||||
@@ -1417,6 +1430,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
// Caret.
|
||||
let focused = resp.has_focus() || app.canvas.focused;
|
||||
// After an automatic change, the web's text agent starts afresh (`keys::note_automatic_change`).
|
||||
// Only there: on X11 an interrupt recreates the input context and fcitx drops later keys (#1169).
|
||||
let ime_reset = std::mem::take(&mut app.canvas.ime_reset) && cfg!(target_arch = "wasm32") && app.canvas.ime_preedit.is_empty();
|
||||
// Editing an equation: shade it and draw the caret inside it.
|
||||
let mut math_caret = false;
|
||||
if let Some(m) = app.session.math.clone()
|
||||
@@ -1450,7 +1466,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
rect: cr,
|
||||
cursor_rect: cr,
|
||||
purpose: Default::default(),
|
||||
should_interrupt_composition: false,
|
||||
should_interrupt_composition: ime_reset,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1488,8 +1504,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1));
|
||||
if focused {
|
||||
ui.ctx().output_mut(|o| {
|
||||
o.ime =
|
||||
Some(egui::output::IMEOutput { rect: cr, cursor_rect: cr, purpose: Default::default(), should_interrupt_composition: false });
|
||||
o.ime = Some(egui::output::IMEOutput {
|
||||
rect: cr,
|
||||
cursor_rect: cr,
|
||||
purpose: Default::default(),
|
||||
should_interrupt_composition: ime_reset,
|
||||
});
|
||||
});
|
||||
}
|
||||
if !app.canvas.ime_preedit.is_empty() {
|
||||
@@ -2495,7 +2515,8 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
||||
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
|
||||
{
|
||||
let key = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" };
|
||||
let tip = format!("{link}\n{}", crate::i18n::fmt(tl!("{key}+Click to follow link"), &[("key", key)]));
|
||||
let target = if is_generated_bookmark_link(&link) { tl!("Current Document").to_string() } else { link };
|
||||
let tip = format!("{target}\n{}", crate::i18n::fmt(tl!("{key}+Click to follow link"), &[("key", key)]));
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(tip);
|
||||
});
|
||||
@@ -2990,6 +3011,7 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
// The TOC field sits in the table's heading; its entries are plain paragraphs.
|
||||
ui.separator();
|
||||
item(ui, app, tl!("Update Field"), "references.updateToc", json!({}));
|
||||
item(ui, app, tl!("Remove Table of Contents"), "references.removeToc", json!({}));
|
||||
}
|
||||
let selected_object = wordcraft_engine::cmd::objects::selected(&app.session).is_some();
|
||||
if selected_object || app.session.sel.focus.path.cell().is_some() {
|
||||
@@ -3168,6 +3190,12 @@ fn link_under(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, po
|
||||
})
|
||||
}
|
||||
|
||||
/// A link to a hidden bookmark (`#_Toc…`, `#_Ref…`): a name the program made, which nobody
|
||||
/// chose or sees, so the hover tip says where it goes instead of showing it (#2177).
|
||||
fn is_generated_bookmark_link(link: &str) -> bool {
|
||||
link.strip_prefix("#_").is_some()
|
||||
}
|
||||
|
||||
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
|
||||
/// so a document can't launch `file:` paths, programs or custom-scheme handlers.
|
||||
fn is_followable_link(url: &str) -> bool {
|
||||
@@ -3202,6 +3230,16 @@ mod tests {
|
||||
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight())
|
||||
}
|
||||
|
||||
/// #2177: only a link to a program-made hidden bookmark hides its name in the hover tip.
|
||||
#[test]
|
||||
fn hover_tip_hides_generated_bookmark_names_only() {
|
||||
assert!(is_generated_bookmark_link("#_Toc123456"));
|
||||
assert!(is_generated_bookmark_link("#_Ref42"));
|
||||
assert!(!is_generated_bookmark_link("#Chapter1"));
|
||||
assert!(!is_generated_bookmark_link("https://example.com/_x"));
|
||||
assert!(!is_generated_bookmark_link(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_image_keeps_every_pixel_the_straight_alpha_round_trip_gave() {
|
||||
// Every premultiplied pixel there is: a channel from 0 up to its alpha, for each alpha.
|
||||
@@ -4105,6 +4143,41 @@ mod tests {
|
||||
assert_eq!(text.matches("Fresh heading").count(), 2, "the new heading is listed in the table: {text}");
|
||||
}
|
||||
|
||||
/// #2179: the context menu on a table of contents entry also offers Remove Table of Contents.
|
||||
#[test]
|
||||
fn context_menu_on_a_toc_entry_removes_the_table() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::report()), Default::default());
|
||||
a.run("caret.docStart", json!({})).unwrap();
|
||||
a.run("references.toc", json!({})).unwrap();
|
||||
let is_entry = |b: &wordcraft_doc::Block| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOC1"));
|
||||
let entry = a.session.doc.body.iter().position(|b| is_entry(b)).expect("a TOC entry");
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let c = h.state_mut().session.layout().caret(&Pos::body(entry, 2)).expect("the entry is laid out");
|
||||
let at = page_to_screen(h.state_mut(), c.page, c.x + 1.0, c.top + c.height / 2.0).expect("on screen");
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
}
|
||||
h.step();
|
||||
h.get_by_label_contains("Remove Table of Contents").click();
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
assert!(!h.state().session.doc.body.iter().any(|b| is_entry(b)), "the table is gone");
|
||||
}
|
||||
|
||||
/// #996: on macOS a Control-click is the right-click (the trackpad's and the one-button
|
||||
/// mouse's way to it), so it opens the same menu and leaves the caret where the menu needs it.
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -510,10 +510,10 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!("OK"))), tl!("Cancel"));
|
||||
if cancelb.clicked() {
|
||||
out = Some(false);
|
||||
}
|
||||
let okb = ui.add(crate::widgets::primary_button(ui, tl!("OK")));
|
||||
let typing = ui.ctx().memory(|m| m.focused().is_some());
|
||||
if okb.clicked() || (!typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||
out = Some(true);
|
||||
|
||||
@@ -269,6 +269,8 @@ pub enum Dialog {
|
||||
Link {
|
||||
url: String,
|
||||
text: String,
|
||||
/// Focus and select the address when the dialog first opens.
|
||||
focus: bool,
|
||||
/// "Place in This Document" instead of "Existing File or Web Page".
|
||||
place: bool,
|
||||
/// The document's places (`link.targets`), read when that tab is first shown.
|
||||
@@ -1008,7 +1010,9 @@ impl Dialog {
|
||||
valign: sp.valign,
|
||||
}
|
||||
}
|
||||
"link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text(), place: false, targets: None, pick: None },
|
||||
"link" => {
|
||||
Dialog::Link { url: "https://".into(), text: app.session.selected_text(), place: false, targets: None, pick: None, focus: true }
|
||||
}
|
||||
"bookmark" => Dialog::Bookmark { name: bookmark_at_caret(&app.session), by_location: false, hidden: false, deleted: false },
|
||||
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
|
||||
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
|
||||
@@ -1427,6 +1431,24 @@ fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, c
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel key for dialogs: Escape everywhere, plus ⌘. (Command+Period) on macOS.
|
||||
pub(crate) fn cancel_key_pressed(ui: &Ui) -> bool {
|
||||
ui.input(|i| i.key_pressed(egui::Key::Escape) || (cfg!(target_os = "macos") && i.modifiers.command && i.key_pressed(egui::Key::Period)))
|
||||
}
|
||||
|
||||
/// Lays out an OK and a Cancel button right-to-left, in the platform's order:
|
||||
/// macOS puts Cancel left of the default OK button; Windows and Linux put OK left of Cancel.
|
||||
/// Returns `(ok, cancel)` responses. Call inside a right-to-left layout.
|
||||
pub(crate) fn ok_cancel_responses(ui: &mut Ui, draw_ok: impl FnOnce(&mut Ui) -> egui::Response, cancel: &str) -> (egui::Response, egui::Response) {
|
||||
if cfg!(target_os = "macos") {
|
||||
let okb = draw_ok(ui);
|
||||
(okb, ui.button(cancel))
|
||||
} else {
|
||||
let cancelb = ui.button(cancel);
|
||||
(draw_ok(ui), cancelb)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
@@ -1434,10 +1456,10 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Enter and Escape belong to the Colors dialog while it is open above this one.
|
||||
let keys = !crate::colors::is_open(ui.ctx());
|
||||
if ui.button(tl!("Cancel")).clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Escape))) {
|
||||
let (okb, cancelb) = ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, ok)), tl!("Cancel"));
|
||||
if cancelb.clicked() || (keys && cancel_key_pressed(ui)) {
|
||||
r.1 = true;
|
||||
}
|
||||
let okb = ui.add(crate::widgets::primary_button(ui, ok));
|
||||
if okb.clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||
r.0 = true;
|
||||
}
|
||||
@@ -2011,7 +2033,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Link { url, text, place, targets, pick } => {
|
||||
Dialog::Link { url, text, place, targets, pick, focus } => {
|
||||
// Word's "Link to:" choices; the places are read once, when that tab is first shown.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Link to:"));
|
||||
@@ -2027,7 +2049,13 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.end_row();
|
||||
if !*place {
|
||||
ui.label(tl!("Address:"));
|
||||
ui.text_edit_singleline(url);
|
||||
let mut out = egui::TextEdit::singleline(url).id(egui::Id::new("link_address")).show(ui);
|
||||
if std::mem::take(focus) {
|
||||
out.response.request_focus();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(url.chars().count()));
|
||||
out.state.cursor.set_char_range(Some(all));
|
||||
out.state.store(ui.ctx(), out.response.id);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
@@ -3183,6 +3211,10 @@ fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_location: &mut
|
||||
ui.set_width(232.0);
|
||||
ui.set_height(150.0);
|
||||
egui::ScrollArea::vertical().id_salt("bookmark_list").max_height(150.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
if marks.is_empty() {
|
||||
// #1904: a blank box says nothing; reuse the Field dialog's wording.
|
||||
ui.label(egui::RichText::new(tl!("The document has no bookmarks yet.")).small().color(t.text_dim));
|
||||
}
|
||||
for n in &marks {
|
||||
if ui.selectable_label(*n == *name, n.as_str()).clicked() {
|
||||
*name = n.clone();
|
||||
@@ -3731,6 +3763,34 @@ mod tests {
|
||||
assert_eq!(h.state().session.doc.bookmarks().len(), 3, "one undo step");
|
||||
}
|
||||
|
||||
/// #1904: with no bookmarks the list says so instead of a blank box.
|
||||
#[test]
|
||||
fn the_bookmark_dialog_says_when_there_are_no_bookmarks() {
|
||||
let mut app = app_with("Alpha beta gamma");
|
||||
app.dialog = Dialog::open("bookmark", &mut app);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
let texts: Vec<String> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(texts.iter().any(|t| t.contains("The document has no bookmarks yet.")), "placeholder shown");
|
||||
}
|
||||
|
||||
/// Find, Replace and Go To are one dialog with three tabs (#712).
|
||||
#[test]
|
||||
fn find_replace_and_go_to_are_one_dialog() {
|
||||
|
||||
@@ -164,10 +164,12 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
// #1906: no forced label color; the theme button text reads on the theme fill.
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.button(tl!("OK")), tl!("Cancel"));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
done = true;
|
||||
}
|
||||
if ui.button(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).clicked() {
|
||||
if okb.clicked() {
|
||||
match app.run("developer.properties", f.params()) {
|
||||
Ok(_) => done = true,
|
||||
Err(e) => app.status(e),
|
||||
|
||||
@@ -84,10 +84,11 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str, cancel: &str, ok_enabled: bool) ->
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!(cancel)).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
let (okb, cancelb) =
|
||||
crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!(cancel));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
r.1 = true;
|
||||
}
|
||||
let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok)));
|
||||
if okb.clicked() || (ok_enabled && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||
r.0 = true;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@ pub(crate) fn list(ui: &mut Ui, id: &str, size: egui::Vec2, rows: &[(String, Str
|
||||
egui::Frame::new().stroke(ui.visuals().widgets.noninteractive.bg_stroke).inner_margin(2.0).show(ui, |ui| {
|
||||
ui.set_width(size.x);
|
||||
ui.set_height(size.y);
|
||||
if rows.is_empty() {
|
||||
// #1905: an empty list says so instead of a blank box.
|
||||
ui.centered_and_justified(|ui| ui.label(egui::RichText::new(tl!("(none)")).weak()));
|
||||
return;
|
||||
}
|
||||
let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
|
||||
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
|
||||
@@ -289,4 +294,46 @@ mod tests {
|
||||
c.apply_prefs(serde_json::from_str(r#"{"keyboard": {"assigned": {"Mod+Q": "gone.command", "Bad+": 1}, "removed": 7}}"#).unwrap());
|
||||
assert!(c.session.keymap.is_empty());
|
||||
}
|
||||
|
||||
/// #1905: a command with no shortcut shows (none) instead of a blank keys box.
|
||||
#[test]
|
||||
fn keys_list_without_shortcuts_says_none() {
|
||||
use super::customize_keyboard;
|
||||
|
||||
let app = || WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
let mut a = app();
|
||||
a.run("tools.customizeKeyboard", json!({})).unwrap();
|
||||
let id = a
|
||||
.session
|
||||
.registry
|
||||
.all()
|
||||
.iter()
|
||||
.find(|s| a.session.keymap.keys_for(&a.session.registry, s.id).is_empty())
|
||||
.map(|s| s.id.to_string())
|
||||
.expect("a command with no shortcut");
|
||||
let mut form = match a.dialog.take() {
|
||||
Some(crate::dialogs::Dialog::CustomizeKeyboard { form }) => form,
|
||||
_ => panic!("expected the Customize Keyboard dialog"),
|
||||
};
|
||||
form.command = id;
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0));
|
||||
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
|
||||
customize_keyboard(&mut a, ui, &mut form);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
a.dialog = Some(crate::dialogs::Dialog::CustomizeKeyboard { form });
|
||||
let texts: Vec<String> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(texts.iter().any(|t| t.contains("(none)")), "empty keys list says (none)");
|
||||
}
|
||||
}
|
||||
@@ -107,10 +107,11 @@ fn buttons(ui: &mut Ui, ok: &str, ok_enabled: bool) -> (bool, bool) {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
let (okb, cancelb) =
|
||||
crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!("Cancel"));
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
r.1 = true;
|
||||
}
|
||||
let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok)));
|
||||
if okb.clicked() {
|
||||
r.0 = true;
|
||||
}
|
||||
|
||||
@@ -361,6 +361,11 @@ pub struct TrackForm {
|
||||
/// Hex, empty = automatic.
|
||||
pub changed_lines_color: String,
|
||||
pub track_formatting: bool,
|
||||
pub move_from_mark: String,
|
||||
/// Hex, empty = by author.
|
||||
pub move_from_color: String,
|
||||
pub move_to_mark: String,
|
||||
pub move_to_color: String,
|
||||
}
|
||||
|
||||
impl TrackForm {
|
||||
@@ -383,6 +388,10 @@ impl TrackForm {
|
||||
changed_lines: s("changedLines"),
|
||||
changed_lines_color: c("changedLinesColor"),
|
||||
track_formatting: b("trackFormatting"),
|
||||
move_from_mark: s("moveFromMark"),
|
||||
move_from_color: c("moveFromColor"),
|
||||
move_to_mark: s("moveToMark"),
|
||||
move_to_color: c("moveToColor"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +412,10 @@ impl TrackForm {
|
||||
"changedLines": self.changed_lines,
|
||||
"changedLinesColor": or(&self.changed_lines_color, "auto"),
|
||||
"trackFormatting": self.track_formatting,
|
||||
"moveFromMark": self.move_from_mark,
|
||||
"moveFromColor": or(&self.move_from_color, "byAuthor"),
|
||||
"moveToMark": self.move_to_mark,
|
||||
"moveToColor": or(&self.move_to_color, "byAuthor"),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -451,6 +464,44 @@ fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_lab
|
||||
});
|
||||
}
|
||||
|
||||
/// How insertions (and moved text where it went) can be marked: (value, label).
|
||||
const INSERT_MARKS: &[(&str, &str)] = &[
|
||||
("underline", "Underline"),
|
||||
("doubleUnderline", "Double Underline"),
|
||||
("bold", "Bold"),
|
||||
("italic", "Italic"),
|
||||
("strikethrough", "Strikethrough"),
|
||||
("colorOnly", "Color only"),
|
||||
("none", "None"),
|
||||
];
|
||||
|
||||
/// How deletions (and the place moved text was taken from) can be marked, in Word for Mac's
|
||||
/// order: (value, label).
|
||||
const DELETE_MARKS: &[(&str, &str)] = &[
|
||||
("none", "(none)"),
|
||||
("colorOnly", "Color only"),
|
||||
("bold", "Bold"),
|
||||
("italic", "Italic"),
|
||||
("underline", "Underline"),
|
||||
("doubleUnderline", "Double Underline"),
|
||||
("strikethrough", "Strikethrough"),
|
||||
("hidden", "Hidden"),
|
||||
("caret", "^"),
|
||||
("hash", "#"),
|
||||
("doubleStrikethrough", "Double Strikethrough"),
|
||||
];
|
||||
|
||||
/// How moved text is marked where it went, in Word for Mac's order: (value, label).
|
||||
const MOVE_TO_MARKS: &[(&str, &str)] = &[
|
||||
("none", "(none)"),
|
||||
("colorOnly", "Color only"),
|
||||
("bold", "Bold"),
|
||||
("italic", "Italic"),
|
||||
("underline", "Underline"),
|
||||
("doubleUnderline", "Double Underline"),
|
||||
("strikethrough", "Strikethrough"),
|
||||
];
|
||||
|
||||
/// Track Changes Options. Returns true to close.
|
||||
pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool {
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
@@ -482,37 +533,11 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
|
||||
heading(ui, "Advanced");
|
||||
egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Insertions:"));
|
||||
choice(
|
||||
ui,
|
||||
"tco_ins",
|
||||
&mut f.insert_mark,
|
||||
&[
|
||||
("underline", "Underline"),
|
||||
("doubleUnderline", "Double Underline"),
|
||||
("bold", "Bold"),
|
||||
("italic", "Italic"),
|
||||
("strikethrough", "Strikethrough"),
|
||||
("colorOnly", "Color only"),
|
||||
("none", "None"),
|
||||
],
|
||||
);
|
||||
choice(ui, "tco_ins", &mut f.insert_mark, INSERT_MARKS);
|
||||
color(ui, &theme, &mut f.insert_color, "By author", None);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Deletions:"));
|
||||
choice(
|
||||
ui,
|
||||
"tco_del",
|
||||
&mut f.delete_mark,
|
||||
&[
|
||||
("strikethrough", "Strikethrough"),
|
||||
("doubleStrikethrough", "Double Strikethrough"),
|
||||
("hidden", "Hidden"),
|
||||
("caret", "^"),
|
||||
("hash", "#"),
|
||||
("underline", "Underline"),
|
||||
("colorOnly", "Color only"),
|
||||
],
|
||||
);
|
||||
choice(ui, "tco_del", &mut f.delete_mark, DELETE_MARKS);
|
||||
color(ui, &theme, &mut f.delete_color, "By author", None);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Changed lines:"));
|
||||
@@ -525,6 +550,17 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool
|
||||
color(ui, &theme, &mut f.changed_lines_color, "Automatic", Some(wordcraft_layout::display::CHANGE_BAR));
|
||||
ui.end_row();
|
||||
});
|
||||
heading(ui, "Moves");
|
||||
egui::Grid::new("tco_moves").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Moved from:"));
|
||||
choice(ui, "tco_move_from", &mut f.move_from_mark, DELETE_MARKS);
|
||||
ui.push_id("tco_move_from_color", |ui| color(ui, &theme, &mut f.move_from_color, "By author", None));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Moved to:"));
|
||||
choice(ui, "tco_move_to", &mut f.move_to_mark, MOVE_TO_MARKS);
|
||||
ui.push_id("tco_move_to_color", |ui| color(ui, &theme, &mut f.move_to_color, "By author", None));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.checkbox(&mut f.track_formatting, tl!("Track formatting"));
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
@@ -599,4 +635,63 @@ mod tests {
|
||||
b.apply_prefs(saved);
|
||||
assert_eq!(b.session.prefs.markup.changed_lines, wordcraft_layout::display::ChangeBar::Right);
|
||||
}
|
||||
|
||||
/// Gap #5: the Moves rows (Moved from / Moved to: mark and colour) start at green double
|
||||
/// strikethrough and double underline, and OK applies them. There is no Track moves
|
||||
/// checkbox (as in Word for Mac), so OK leaves the document's setting alone.
|
||||
#[test]
|
||||
fn track_changes_options_moves_rows_apply_marks_and_colours() {
|
||||
use wordcraft_layout::display::{DeleteMark, InsertMark, MOVE_COLOR};
|
||||
let mut a = app();
|
||||
a.session.run("review.trackingOptions", &json!({"trackMoves": false})).unwrap();
|
||||
a.run("review.trackingOptions", json!({})).unwrap();
|
||||
let Some(Dialog::TrackOptions { form }) = &mut a.dialog else { panic!("dialog") };
|
||||
assert_eq!(
|
||||
(form.move_from_mark.as_str(), form.move_from_color.clone(), form.move_to_mark.as_str(), form.move_to_color.clone()),
|
||||
("doubleStrikethrough", MOVE_COLOR.hex(), "doubleUnderline", MOVE_COLOR.hex())
|
||||
);
|
||||
form.move_from_mark = "strikethrough".into();
|
||||
form.move_from_color = "FF0000".into();
|
||||
form.move_to_mark = "bold".into();
|
||||
form.move_to_color = String::new();
|
||||
let params = form.params();
|
||||
assert!(params.get("trackMoves").is_none(), "{params}");
|
||||
a.dialog = None;
|
||||
a.run("review.trackingOptions", params).unwrap();
|
||||
let m = &a.session.prefs.markup;
|
||||
assert_eq!((m.move_from_mark, m.move_from_color), (DeleteMark::Strikethrough, Some(wordcraft_doc::Rgb(0xFF, 0, 0))));
|
||||
assert_eq!((m.move_to_mark, m.move_to_color), (InsertMark::Bold, None), "empty = by author");
|
||||
assert!(!a.session.doc.settings.track_moves, "the document setting is untouched");
|
||||
}
|
||||
|
||||
/// Gap #5, Word for Mac 16.113.4 (2026-10-15): the Deletions and Moved from lists, and the
|
||||
/// Moved to list, in Word's order; every choice is one `review.trackingOptions` takes.
|
||||
#[test]
|
||||
fn track_changes_options_mark_lists_follow_word() {
|
||||
use wordcraft_layout::display::{DeleteMark, InsertMark};
|
||||
let values = |l: &[(&str, &str)]| l.iter().map(|(v, _)| v.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
values(super::DELETE_MARKS),
|
||||
[
|
||||
"none",
|
||||
"colorOnly",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"doubleUnderline",
|
||||
"strikethrough",
|
||||
"hidden",
|
||||
"caret",
|
||||
"hash",
|
||||
"doubleStrikethrough"
|
||||
]
|
||||
);
|
||||
assert_eq!(values(super::MOVE_TO_MARKS), ["none", "colorOnly", "bold", "italic", "underline", "doubleUnderline", "strikethrough"]);
|
||||
assert!(super::DELETE_MARKS.iter().all(|(v, _)| DeleteMark::parse(v).is_some()));
|
||||
assert!(super::MOVE_TO_MARKS.iter().chain(super::INSERT_MARKS).all(|(v, _)| InsertMark::parse(v).is_some()));
|
||||
let mut a = app();
|
||||
for (v, _) in super::DELETE_MARKS {
|
||||
a.run("review.trackingOptions", json!({"deleteMark": v, "moveFromMark": v})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,10 +147,19 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
apply = ui.button(tl!("Apply")).clicked();
|
||||
let label = if f.endnote { "Insert Endnote" } else { "Insert Footnote" };
|
||||
insert = ui.add(crate::widgets::primary_button(ui, tl!(label))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if cfg!(target_os = "macos") {
|
||||
// macOS: [Apply] [Cancel] [Insert], the default button rightmost.
|
||||
insert = ui.add(crate::widgets::primary_button(ui, tl!(label))).clicked() || enter;
|
||||
cancel = ui.button(tl!("Cancel")).clicked();
|
||||
apply = ui.button(tl!("Apply")).clicked();
|
||||
} else {
|
||||
cancel = ui.button(tl!("Cancel")).clicked();
|
||||
apply = ui.button(tl!("Apply")).clicked();
|
||||
insert = ui.add(crate::widgets::primary_button(ui, tl!(label))).clicked() || enter;
|
||||
}
|
||||
cancel |= crate::dialogs::cancel_key_pressed(ui);
|
||||
});
|
||||
});
|
||||
if insert || apply {
|
||||
|
||||
@@ -68,10 +68,14 @@ fn buttons(ui: &mut Ui, ok_enabled: bool) -> (bool, bool) {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
let (okb, cancelb) = crate::dialogs::ok_cancel_responses(
|
||||
ui,
|
||||
|ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!("OK"))),
|
||||
tl!("Cancel"),
|
||||
);
|
||||
if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) {
|
||||
r.1 = true;
|
||||
}
|
||||
let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!("OK")));
|
||||
if okb.clicked() {
|
||||
r.0 = true;
|
||||
}
|
||||
@@ -349,6 +353,8 @@ impl AutoCorrectForm {
|
||||
"bullets": o.bullets,
|
||||
"numbering": o.numbering,
|
||||
"borderLines": o.border_lines,
|
||||
"autoAddFirstLetter": o.auto_add_first_letter,
|
||||
"autoAddInitialCaps": o.auto_add_initial_caps,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -479,6 +485,8 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
|
||||
fn exceptions(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.checkbox(&mut f.options.auto_add_first_letter, tl!("Automatically add words to list (First Letter)"));
|
||||
ui.checkbox(&mut f.options.auto_add_initial_caps, tl!("Automatically add words to list (INitial CAps)"));
|
||||
ui.label(tl!("Don't capitalize after:"));
|
||||
let list = f.options.exception_list();
|
||||
let mut picked = None;
|
||||
@@ -654,6 +662,9 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) {
|
||||
ui.label(egui::RichText::new(tl!("The password is optional; without it anyone can stop the protection.")).small());
|
||||
passwords(ui, st, true);
|
||||
ui.horizontal(|ui| {
|
||||
// macOS puts Cancel before OK.
|
||||
let mac = cfg!(target_os = "macos");
|
||||
let mut cancel = mac && ui.button(tl!("Cancel")).clicked();
|
||||
if ui.button(tl!("OK")).clicked() {
|
||||
if st.password != st.confirm {
|
||||
st.message = tl!("The passwords don't match.").to_string();
|
||||
@@ -677,7 +688,8 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) {
|
||||
st.confirm = Password::default();
|
||||
}
|
||||
}
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
cancel |= !mac && ui.button(tl!("Cancel")).clicked();
|
||||
if cancel {
|
||||
st.asking = false;
|
||||
st.password = Password::default();
|
||||
st.confirm = Password::default();
|
||||
@@ -738,10 +750,13 @@ fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str
|
||||
ui.label(egui::RichText::new(tl!("Type the password to stop the protection.")).small());
|
||||
passwords(ui, st, false);
|
||||
ui.horizontal(|ui| {
|
||||
// macOS puts Cancel before OK.
|
||||
let mac = cfg!(target_os = "macos");
|
||||
let cancel_first = mac && ui.button(tl!("Cancel")).clicked();
|
||||
if ui.button(tl!("OK")).clicked() {
|
||||
stop(app, st);
|
||||
}
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
if cancel_first || (!mac && ui.button(tl!("Cancel")).clicked()) {
|
||||
st.asking = false;
|
||||
st.password = Password::default();
|
||||
}
|
||||
|
||||
@@ -275,10 +275,15 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(name).desired_width(140.0));
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
// macOS puts Cancel before OK.
|
||||
let mac = cfg!(target_os = "macos");
|
||||
if mac && ui.button(tl!("Cancel")).clicked() {
|
||||
done = Some(None);
|
||||
}
|
||||
if ui.button(tl!("OK")).clicked() || enter {
|
||||
done = Some(Some(name.clone()));
|
||||
}
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
if !mac && ui.button(tl!("Cancel")).clicked() {
|
||||
done = Some(None);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -439,8 +439,12 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
|
||||
let enter = !location_done && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
cancel |= ui.button(tl!("Cancel")).clicked();
|
||||
let ok = ui.add(egui::Button::new(egui::RichText::new(tl!(b.ok_label())).color(egui::Color32::WHITE)).fill(APP_COLOR));
|
||||
let (ok, cancelb) = crate::dialogs::ok_cancel_responses(
|
||||
ui,
|
||||
|ui| ui.add(egui::Button::new(egui::RichText::new(tl!(b.ok_label())).color(egui::Color32::WHITE)).fill(APP_COLOR)),
|
||||
tl!("Cancel"),
|
||||
);
|
||||
cancel |= cancelb.clicked();
|
||||
pick |= ok.clicked() || enter;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,9 +333,9 @@ fn untranslated_interface_text() {
|
||||
complete_catalogs_have_every_string_the_interface_looked_up(pseudo::looked_up());
|
||||
}
|
||||
|
||||
/// Russian, Hungarian, Turkish, Bulgarian, Italian, Swedish and Croatian cover everything the audit saw
|
||||
/// translated, plus the equation gallery's labels (shown in menus the audit doesn't open one by
|
||||
/// one). Numbered style names go through a template.
|
||||
/// Russian, Hungarian, Turkish, Bulgarian, Italian, Swedish, Croatian and Polish cover everything the
|
||||
/// audit saw translated, plus the equation gallery's labels (shown in menus the audit doesn't open
|
||||
/// one by one). Numbered style names go through a template.
|
||||
fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::collections::BTreeSet<String>) {
|
||||
use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS};
|
||||
keys.extend(BUILT_INS.iter().map(|(_, label, _)| label.to_string()));
|
||||
@@ -379,7 +379,7 @@ fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::co
|
||||
// translate: its placeholder names aren't words the interface shows.
|
||||
let words = |s: &str| english_words(&s.split('{').map(|p| p.split_once('}').map_or(p, |(_, rest)| rest)).collect::<String>());
|
||||
let mut report = String::new();
|
||||
for code in ["ru", "hu", "tr", "bg", "it", "sv", "hr"] {
|
||||
for code in ["ru", "hu", "tr", "bg", "it", "sv", "hr", "pl"] {
|
||||
let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN);
|
||||
let missing: Vec<&String> =
|
||||
keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect();
|
||||
|
||||
@@ -515,6 +515,10 @@
|
||||
More templates → Още шаблони →
|
||||
Move or Resize Преместване или преоразмеряване
|
||||
Multilevel List Многостепенен списък
|
||||
Multilevel List Library Библиотека с многостепенни списъци
|
||||
Article I. Член I.
|
||||
Chapter 1 Глава 1
|
||||
Section 1.01 Раздел 1.01
|
||||
Multiple Pages Няколко страници
|
||||
Name (A–Z) Име (А–Я)
|
||||
Name: Име:
|
||||
@@ -2211,6 +2215,8 @@
|
||||
Math AutoCorrect Автокорекция на математически изрази
|
||||
More >> Още >>
|
||||
Moves Премествания
|
||||
Moved from: Преместено от:
|
||||
Moved to: Преместено в:
|
||||
New document Нов документ
|
||||
No changes (read only) Без промени (само за четене)
|
||||
Only allow this kind of editing: Разрешаване само на този вид редактиране:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -711,6 +711,10 @@
|
||||
Move Past Citation Přejít za citaci
|
||||
Move Past\nCitation Přejít za\ncitaci
|
||||
Multilevel List Víceúrovňový seznam
|
||||
Multilevel List Library Knihovna víceúrovňových seznamů
|
||||
Article I. Článek I.
|
||||
Chapter 1 Kapitola 1
|
||||
Section 1.01 Oddíl 1.01
|
||||
Multiple Pages Více stránek
|
||||
Name (A–Z) Jméno (A–Z)
|
||||
Name of the file this document is saved as Název souboru dokumentu
|
||||
@@ -2104,6 +2108,8 @@ preview Title Ukázka
|
||||
Math AutoCorrect Automatické opravy matematiky
|
||||
More >> Více >>
|
||||
Moves Přesuny
|
||||
Moved from: Přesunuto odtud:
|
||||
Moved to: Přesunuto sem:
|
||||
New document Nový dokument
|
||||
No changes (read only) Žádné změny (jen pro čtení)
|
||||
Only allow this kind of editing: Povolit pouze tento typ úprav:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -511,6 +511,10 @@
|
||||
Modify… Ändern…
|
||||
Move or Resize Verschieben oder Größe ändern
|
||||
Multilevel List Mehrstufige Liste
|
||||
Multilevel List Library Bibliothek mehrstufiger Listen
|
||||
Article I. Artikel I.
|
||||
Chapter 1 Kapitel 1
|
||||
Section 1.01 Abschnitt 1.01
|
||||
Multiple Pages Mehrere Seiten
|
||||
Name (A–Z) Name (A–Z)
|
||||
Name: Name:
|
||||
@@ -1527,6 +1531,8 @@
|
||||
Math AutoCorrect Math. AutoKorrektur
|
||||
More >> Mehr >>
|
||||
Moves Verschiebungen
|
||||
Moved from: Verschoben von:
|
||||
Moved to: Verschoben nach:
|
||||
New document Neues Dokument
|
||||
No changes (read only) Keine Änderungen (schreibgeschützt)
|
||||
Only allow this kind of editing: Nur diese Art der Bearbeitung zulassen:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -583,6 +583,10 @@
|
||||
More templates → Más plantillas →
|
||||
Move or Resize Mover o cambiar tamaño
|
||||
Multilevel List Lista multinivel
|
||||
Multilevel List Library Biblioteca de listas multinivel
|
||||
Article I. Artículo I.
|
||||
Chapter 1 Capítulo 1
|
||||
Section 1.01 Sección 1.01
|
||||
Multiple Pages Varias páginas
|
||||
Name (A–Z) Nombre (A–Z)
|
||||
Name: Nombre:
|
||||
@@ -1524,6 +1528,8 @@
|
||||
Math AutoCorrect Autocorrección matemática
|
||||
More >> Más >>
|
||||
Moves Movimientos
|
||||
Moved from: Movido desde:
|
||||
Moved to: Movido a:
|
||||
New document Documento nuevo
|
||||
No changes (read only) Sin cambios (solo lectura)
|
||||
Only allow this kind of editing: Permitir solo este tipo de edición:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -459,6 +459,10 @@
|
||||
More templates → Veel malle →
|
||||
Move or Resize Teisalda või muuda suurust
|
||||
Multilevel List Mitmetasemeline loend
|
||||
Multilevel List Library Mitmetasemeliste loendite teek
|
||||
Article I. Artikkel I.
|
||||
Chapter 1 Peatükk 1
|
||||
Section 1.01 Jaotis 1.01
|
||||
Multiple Pages Mitu lehte
|
||||
Name (A–Z) Nimi (A–Z)
|
||||
Name: Nimi:
|
||||
@@ -1503,6 +1507,8 @@
|
||||
Math AutoCorrect Matemaatika automaatparandus
|
||||
More >> Rohkem >>
|
||||
Moves Teisaldused
|
||||
Moved from: Teisaldatud kohast:
|
||||
Moved to: Teisaldatud kohta:
|
||||
New document Uus dokument
|
||||
No changes (read only) Muudatusi pole (kirjutuskaitstud)
|
||||
Only allow this kind of editing: Luba ainult seda tüüpi muutmist:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 845 and column 23.
|
@@ -525,6 +525,10 @@
|
||||
Modify… Modifier…
|
||||
Move or Resize Déplacer ou redimensionner
|
||||
Multilevel List Liste à plusieurs niveaux
|
||||
Multilevel List Library Bibliothèque de listes à plusieurs niveaux
|
||||
Article I. Article I.
|
||||
Chapter 1 Chapitre 1
|
||||
Section 1.01 Section 1.01
|
||||
Multiple Pages Plusieurs pages
|
||||
Name (A–Z) Nom (A–Z)
|
||||
Name: Nom :
|
||||
@@ -1409,6 +1413,8 @@
|
||||
Math AutoCorrect Correction automatique mathématique
|
||||
More >> Plus >>
|
||||
Moves Déplacements
|
||||
Moved from: Déplacé depuis :
|
||||
Moved to: Déplacé vers :
|
||||
New document Nouveau document
|
||||
No changes (read only) Aucune modification (lecture seule)
|
||||
Only allow this kind of editing: Autoriser uniquement ce type de modification :
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1365,6 +1365,10 @@ template Letter Pismo
|
||||
Moved to Premješteno u
|
||||
Moves Premještanja
|
||||
Multilevel List Višerazinski popis
|
||||
Multilevel List Library Biblioteka višerazinskih popisa
|
||||
Article I. Članak I.
|
||||
Chapter 1 Poglavlje 1
|
||||
Section 1.01 Odjeljak 1.01
|
||||
Multiple Pages Više stranica
|
||||
Name Naziv
|
||||
Name (A–Z) Naziv (A–Ž)
|
||||
@@ -2651,3 +2655,5 @@ preview Title Naslov
|
||||
The measurement must be between {min} and {max}. Mjera mora biti između {min} i {max}.
|
||||
Recently Used Fonts Nedavno korišteni fontovi
|
||||
All Fonts Svi fontovi
|
||||
Moved from: Premješteno iz:
|
||||
Moved to: Premješteno u:
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -440,6 +440,10 @@
|
||||
Modify Style Stílus módosítása
|
||||
Modify… Módosítás…
|
||||
Multilevel List Többszintű lista
|
||||
Multilevel List Library Többszintű listák tára
|
||||
Article I. I. cikk
|
||||
Chapter 1 1. fejezet
|
||||
Section 1.01 1.01. szakasz
|
||||
Multiple Pages Több oldal
|
||||
Name (A–Z) Név (A–Z)
|
||||
Name: Név:
|
||||
@@ -1864,6 +1868,8 @@
|
||||
Math AutoCorrect Matematikai automatikus javítás
|
||||
More >> Több >>
|
||||
Moves Áthelyezések
|
||||
Moved from: Áthelyezve innen:
|
||||
Moved to: Áthelyezve ide:
|
||||
New document Új dokumentum
|
||||
No changes (read only) Nincs módosítás (csak olvasható)
|
||||
Only allow this kind of editing: Csak ez a fajta szerkesztés engedélyezett:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -511,6 +511,10 @@
|
||||
More templates → Altri modelli →
|
||||
Move or Resize Sposta o ridimensiona
|
||||
Multilevel List Elenco a più livelli
|
||||
Multilevel List Library Raccolta elenchi a più livelli
|
||||
Article I. Articolo I.
|
||||
Chapter 1 Capitolo 1
|
||||
Section 1.01 Sezione 1.01
|
||||
Multiple Pages Più pagine
|
||||
Name (A–Z) Nome (A–Z)
|
||||
Name: Nome:
|
||||
@@ -1594,6 +1598,8 @@
|
||||
Math AutoCorrect Correzione automatica formule
|
||||
More >> Altro >>
|
||||
Moves Spostamenti
|
||||
Moved from: Spostato da:
|
||||
Moved to: Spostato in:
|
||||
New document Nuovo documento
|
||||
No changes (read only) Nessuna modifica (sola lettura)
|
||||
Only allow this kind of editing: Consenti solo questo tipo di modifica:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -568,6 +568,10 @@
|
||||
Modify… 変更…
|
||||
Move or Resize 移動またはサイズ変更
|
||||
Multilevel List アウトライン
|
||||
Multilevel List Library アウトライン ライブラリ
|
||||
Article I. 第 I 条
|
||||
Chapter 1 第 1 章
|
||||
Section 1.01 第 1.01 節
|
||||
Multiple Pages 複数ページ
|
||||
Name (A–Z) 名前(A–Z)
|
||||
Name: 名前:
|
||||
@@ -1484,6 +1488,8 @@
|
||||
Math AutoCorrect 数式オートコレクト
|
||||
More >> オプション >>
|
||||
Moves 移動
|
||||
Moved from: 移動元:
|
||||
Moved to: 移動先:
|
||||
New document 新規文書
|
||||
No changes (read only) 変更不可 (読み取り専用)
|
||||
Only allow this kind of editing: 次の種類の編集のみを許可:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 923 and column 23.
|
@@ -562,6 +562,10 @@
|
||||
Modify… 수정…
|
||||
Move or Resize 이동 또는 크기 조정
|
||||
Multilevel List 다단계 목록
|
||||
Multilevel List Library 다단계 목록 라이브러리
|
||||
Article I. 제 I 조
|
||||
Chapter 1 제 1 장
|
||||
Section 1.01 제 1.01 절
|
||||
Multiple Pages 여러 페이지
|
||||
Name (A–Z) 이름순(A–Z)
|
||||
Name: 이름:
|
||||
|
||||
|
@@ -431,6 +431,10 @@
|
||||
More templates → Więcej szablonów →
|
||||
Move or Resize Przenieś lub zmień rozmiar
|
||||
Multilevel List Lista wielopoziomowa
|
||||
Multilevel List Library Biblioteka list wielopoziomowych
|
||||
Article I. Artykuł I.
|
||||
Chapter 1 Rozdział 1
|
||||
Section 1.01 Sekcja 1.01
|
||||
Multiple Pages Wiele stron
|
||||
Name (A–Z) Nazwa (A–Z)
|
||||
Name: Nazwa:
|
||||
@@ -1888,6 +1892,8 @@
|
||||
Move Up Przenieś w górę
|
||||
Move with Text Przenoś z tekstem
|
||||
Moves Przeniesienia
|
||||
Moved from: Przeniesiono z:
|
||||
Moved to: Przeniesiono do:
|
||||
New comments and tracked changes are recorded under this name and these initials. Nowe komentarze i śledzone zmiany są zapisywane pod tą nazwą i tymi inicjałami.
|
||||
New document Nowy dokument
|
||||
New word Nowy wyraz
|
||||
@@ -2571,6 +2577,21 @@ preview Title Tytuł
|
||||
Suggested Sugerowane
|
||||
Set Proofing Language… Ustaw język sprawdzania…
|
||||
Language Preferences… Preferencje językowe…
|
||||
APA APA
|
||||
Chicago Chicago
|
||||
GB/T 7714 GB/T 7714
|
||||
GOST (name sort) GOST (sortowanie według nazwiska)
|
||||
GOST (title sort) GOST (sortowanie według tytułu)
|
||||
Harvard (Anglia) Harvard (Anglia)
|
||||
IEEE IEEE
|
||||
ISO 690 (author-date) ISO 690 (autor–data)
|
||||
ISO 690 (numerical) ISO 690 (numeryczny)
|
||||
MLA MLA
|
||||
SIST02 SIST02
|
||||
Turabian Turabian
|
||||
Large Duży
|
||||
Limit and Granica i
|
||||
Log Logarytm
|
||||
Printer Drukarka
|
||||
Select a printer Wybierz drukarkę
|
||||
Refresh printers Odśwież drukarki
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -569,6 +569,10 @@
|
||||
Modify… Modificar…
|
||||
Move or Resize Mover ou redimensionar
|
||||
Multilevel List Lista de vários níveis
|
||||
Multilevel List Library Biblioteca de listas de vários níveis
|
||||
Article I. Artigo I.
|
||||
Chapter 1 Capítulo 1
|
||||
Section 1.01 Seção 1.01
|
||||
Multiple Pages Várias páginas
|
||||
Name (A–Z) Nome (A–Z)
|
||||
Name: Nome:
|
||||
@@ -1496,6 +1500,8 @@
|
||||
Math AutoCorrect Autocorreção de matemática
|
||||
More >> Mais >>
|
||||
Moves Movimentações
|
||||
Moved from: Movido de:
|
||||
Moved to: Movido para:
|
||||
New document Novo documento
|
||||
No changes (read only) Sem alterações (somente leitura)
|
||||
Only allow this kind of editing: Permitir somente este tipo de edição:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -442,6 +442,10 @@
|
||||
Modify Style Изменить стиль
|
||||
Modify… Изменить…
|
||||
Multilevel List Многоуровневый список
|
||||
Multilevel List Library Библиотека многоуровневых списков
|
||||
Article I. Статья I.
|
||||
Chapter 1 Глава 1
|
||||
Section 1.01 Раздел 1.01
|
||||
Multiple Pages Несколько страниц
|
||||
Name (A–Z) Имя (А–Я)
|
||||
Name: Имя:
|
||||
@@ -2086,6 +2090,8 @@ preview Title Пример
|
||||
Math AutoCorrect Автозамена математическими символами
|
||||
More >> Больше >>
|
||||
Moves Перемещения
|
||||
Moved from: Перемещено из:
|
||||
Moved to: Перемещено в:
|
||||
New document Новый документ
|
||||
No changes (read only) Без изменений (только чтение)
|
||||
Only allow this kind of editing: Разрешить только такой способ редактирования:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -616,6 +616,10 @@
|
||||
Modify… Izmeni…
|
||||
Move or Resize Pomeri ili promeni veličinu
|
||||
Multilevel List Višenivoska lista
|
||||
Multilevel List Library Biblioteka višenivoskih lista
|
||||
Article I. Član I.
|
||||
Chapter 1 Poglavlje 1
|
||||
Section 1.01 Odeljak 1.01
|
||||
Multiple Pages Više strana
|
||||
Name (A–Z) Naziv (A–Š)
|
||||
Name: Naziv:
|
||||
@@ -1494,6 +1498,8 @@
|
||||
Math AutoCorrect Automatska ispravka matematike
|
||||
More >> Više >>
|
||||
Moves Premeštanja
|
||||
Moved from: Premešteno odavde:
|
||||
Moved to: Premešteno ovde:
|
||||
New document Novi dokument
|
||||
No changes (read only) Bez izmena (samo za čitanje)
|
||||
Only allow this kind of editing: Dozvoli samo ovu vrstu uređivanja:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -620,6 +620,10 @@
|
||||
Modify… Измени…
|
||||
Move or Resize Помери или промени величину
|
||||
Multilevel List Вишенивоска листа
|
||||
Multilevel List Library Библиотека вишенивоских листа
|
||||
Article I. Члан I.
|
||||
Chapter 1 Поглавље 1
|
||||
Section 1.01 Одељак 1.01
|
||||
Multiple Pages Више страна
|
||||
Name (A–Z) Назив (А–Ш)
|
||||
Name: Назив:
|
||||
@@ -1498,6 +1502,8 @@
|
||||
Math AutoCorrect Аутоматска исправка математике
|
||||
More >> Више >>
|
||||
Moves Премештања
|
||||
Moved from: Премештено одавде:
|
||||
Moved to: Премештено овде:
|
||||
New document Нови документ
|
||||
No changes (read only) Без измена (само за читање)
|
||||
Only allow this kind of editing: Дозволи само ову врсту уређивања:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -574,6 +574,10 @@
|
||||
More templates → Fler mallar →
|
||||
Move or Resize Flytta eller ändra storlek
|
||||
Multilevel List Lista med flera nivåer
|
||||
Multilevel List Library Bibliotek med listor med flera nivåer
|
||||
Article I. Artikel I.
|
||||
Chapter 1 Kapitel 1
|
||||
Section 1.01 Avsnitt 1.01
|
||||
Multiple Pages Flera sidor
|
||||
Name (A–Z) Namn (A–Ö)
|
||||
Name: Namn:
|
||||
@@ -2666,3 +2670,5 @@ preview Title Titel
|
||||
The measurement must be between {min} and {max}. Måttet måste vara mellan {min} och {max}.
|
||||
Recently Used Fonts Senast använda typsnitt
|
||||
All Fonts Alla typsnitt
|
||||
Moved from: Flyttat från:
|
||||
Moved to: Flyttat till:
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -989,11 +989,17 @@ fn polish_covers_every_key_of_every_other_catalog() {
|
||||
let polish = keys(lang("pl").0.source);
|
||||
// Italian and Hungarian still carry "Style: APA", which the interface no longer shows;
|
||||
// Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line
|
||||
// pieces of gallery labels. Polish matches every other catalog, including the built-in style
|
||||
// names, citation styles and equation gallery.
|
||||
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi" | "sv" | "hr")) {
|
||||
let mut missing: Vec<String> = keys(other.0.source).difference(&polish).cloned().collect();
|
||||
missing.sort();
|
||||
assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code());
|
||||
}
|
||||
for code in ["it", "hu"] {
|
||||
let missing: Vec<String> = keys(lang(code).0.source).difference(&polish).cloned().collect();
|
||||
assert_eq!(missing, ["Style: APA"], "pl vs {code}");
|
||||
}
|
||||
let pl = lang("pl");
|
||||
assert_eq!(tr(pl, "File"), "Plik");
|
||||
assert_eq!(tr(pl, "Home"), "Start");
|
||||
@@ -1002,6 +1008,8 @@ fn polish_covers_every_key_of_every_other_catalog() {
|
||||
assert_eq!(tr(pl, "Save"), "Zapisz");
|
||||
assert_eq!(tr(pl, "Spelling & Grammar"), "Pisownia i gramatyka");
|
||||
assert_eq!(tr(pl, "Polish"), "Polski");
|
||||
assert_eq!(tr(pl, "GOST (name sort)"), "GOST (sortowanie według nazwiska)");
|
||||
assert_eq!(tr(pl, "ISO 690 (author-date)"), "ISO 690 (autor–data)");
|
||||
assert_eq!(tr(pl, "unknown future label"), "unknown future label");
|
||||
assert_eq!(fmt(tr(pl, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Wyeksportowano: draft-{words}.docx");
|
||||
assert_eq!(fmt(tr(pl, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Strona 3 z 9");
|
||||
|
||||
@@ -440,6 +440,10 @@
|
||||
Modify Style Stili Değiştir
|
||||
Modify… Değiştir…
|
||||
Multilevel List Çok Düzeyli Liste
|
||||
Multilevel List Library Çok Düzeyli Liste Kitaplığı
|
||||
Article I. Madde I.
|
||||
Chapter 1 Bölüm 1
|
||||
Section 1.01 Kısım 1.01
|
||||
Multiple Pages Birden Çok Sayfa
|
||||
Name (A–Z) Ad (A–Z)
|
||||
Name: Ad:
|
||||
@@ -2088,6 +2092,8 @@ preview Title Örnek
|
||||
Math AutoCorrect Matematik Otomatik Düzelt
|
||||
More >> Daha fazla >>
|
||||
Moves Taşımalar
|
||||
Moved from: Taşındı (kaynak):
|
||||
Moved to: Taşındı (hedef):
|
||||
New document Yeni belge
|
||||
No changes (read only) Değişiklik yok (salt okunur)
|
||||
Only allow this kind of editing: Yalnızca bu tür düzenlemeye izin ver:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 797 and column 23.
|
@@ -583,6 +583,10 @@
|
||||
Modify… Змінити…
|
||||
Move or Resize Перемістити або змінити розмір
|
||||
Multilevel List Багаторівневий список
|
||||
Multilevel List Library Бібліотека багаторівневих списків
|
||||
Article I. Стаття I.
|
||||
Chapter 1 Глава 1
|
||||
Section 1.01 Розділ 1.01
|
||||
Multiple Pages Кілька сторінок
|
||||
Name (A–Z) Назва (А–Я)
|
||||
Name: Назва:
|
||||
@@ -1496,6 +1500,8 @@
|
||||
Math AutoCorrect Математичне автовиправлення
|
||||
More >> Більше >>
|
||||
Moves Переміщення
|
||||
Moved from: Переміщено звідси:
|
||||
Moved to: Переміщено сюди:
|
||||
New document Новий документ
|
||||
No changes (read only) Без змін (лише читання)
|
||||
Only allow this kind of editing: Дозволити лише такий тип редагування:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -507,6 +507,10 @@
|
||||
More templates → Xem thêm mẫu →
|
||||
Move or Resize Di chuyển hoặc đổi kích thước
|
||||
Multilevel List Danh sách đa cấp
|
||||
Multilevel List Library Thư viện danh sách đa cấp
|
||||
Article I. Điều I.
|
||||
Chapter 1 Chương 1
|
||||
Section 1.01 Mục 1.01
|
||||
Multiple Pages Nhiều trang
|
||||
Name (A–Z) Tên (A–Z)
|
||||
Name: Tên:
|
||||
@@ -2204,6 +2208,8 @@
|
||||
Math AutoCorrect Tự động sửa ký hiệu toán
|
||||
More >> Mở rộng >>
|
||||
Moves Nội dung đã di chuyển
|
||||
Moved from: Đã di chuyển từ:
|
||||
Moved to: Đã di chuyển đến:
|
||||
New document Tài liệu mới
|
||||
No changes (read only) Không cho phép thay đổi (chỉ đọc)
|
||||
Only allow this kind of editing: Chỉ cho phép kiểu chỉnh sửa sau:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user