diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 14b58afcdc..e0dca6dfb0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index abfba697d8..ba8b474093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5223,6 +5223,7 @@ dependencies = [ "sha1", "sha2", "thiserror 2.0.21", + "unicode-bidi", "wordcraft-doc", "wordcraft-geom", "zeroize", diff --git a/ROADMAP.md b/ROADMAP.md index 8a0902fd6b..d9951fa53e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/apps/wordcraft-web/src/prefs.rs b/apps/wordcraft-web/src/prefs.rs index bf5ec46d56..232688c14b 100644 --- a/apps/wordcraft-web/src/prefs.rs +++ b/apps/wordcraft-web/src/prefs.rs @@ -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"); } } } diff --git a/contributors/people.toml b/contributors/people.toml index 6a405d053c..33aa27264d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index d52733af61..ecb1ba0137 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -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, 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; 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 { + 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(|| "".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 = 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"); + } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index fb3c50e350..6870a16b38 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, out: &mut Vec, depth: usize) { - if depth > 16 { + if depth > PARA_WALK_DEPTH { return; } for (i, b) in bl.iter().enumerate() { diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 9498ca4c7b..48c60b7815 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -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![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}"); + } } diff --git a/crates/docbin/src/metadata.rs b/crates/docbin/src/metadata.rs index a07de61056..207591fcc2 100644 --- a/crates/docbin/src/metadata.rs +++ b/crates/docbin/src/metadata.rs @@ -27,7 +27,7 @@ fn u32_at(bytes: &[u8], offset: usize) -> Option { } fn parse(bytes: &[u8]) -> Option { - 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 { .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 { 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 { + 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 { let kind = u32_at(value, 0)?; let count = u32_at(value, 4)? as usize; diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs index b8ac34220c..f9048c5f3e 100644 --- a/crates/docbin/src/table.rs +++ b/crates/docbin/src/table.rs @@ -77,6 +77,7 @@ pub(crate) struct RowInfo { /// automatic `Shd` (`None`) clears it. shading: Vec<(u8, Option)>, 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 { 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 { + 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 { + 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) -> 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); } } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 9e2559ffcc..8c67ff676a 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -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)); +} diff --git a/crates/docbin/src/tests/metadata.rs b/crates/docbin/src/tests/metadata.rs index f36b93df9c..b36a963c47 100644 --- a/crates/docbin/src/tests/metadata.rs +++ b/crates/docbin/src/tests/metadata.rs @@ -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)], num_property_sets: u32) -> Vec { + 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"); +} diff --git a/crates/docx/Cargo.toml b/crates/docx/Cargo.toml index f51956f954..8e599b2a23 100644 --- a/crates/docx/Cargo.toml +++ b/crates/docx/Cargo.toml @@ -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 } diff --git a/crates/docx/src/read/drawing_color.rs b/crates/docx/src/read/drawing_color.rs index 4d2c7b9283..3dd8bc7dcf 100644 --- a/crates/docx/src/read/drawing_color.rs +++ b/crates/docx/src/read/drawing_color.rs @@ -79,7 +79,8 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option { /// 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 { - 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 { "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 { "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#""#); 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); + } } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index d82e6d33e4..7ade359b6c 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -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); diff --git a/crates/docx/src/read/shape_style.rs b/crates/docx/src/read/shape_style.rs index 62d0e01f69..9d66436a99 100644 --- a/crates/docx/src/read/shape_style.rs +++ b/crates/docx/src/read/shape_style.rs @@ -169,18 +169,39 @@ fn dash_ratio(value: &str) -> Option { 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 { - 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 = inner + .trim_end_matches(')') + .split(',') + .take(3) + .filter_map(|x| { + let x = x.trim(); + if let Some(p) = x.strip_suffix('%') { + p.trim().parse::().ok().map(|f| (f.clamp(0.0, 100.0) * 2.55).round() as u8) + } else { + x.parse::().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); } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index d4977fb3a3..967b757041 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -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 { - 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 { + 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 { - 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 { + 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, + ) { + 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 { 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; diff --git a/crates/docx/src/write/bidi.rs b/crates/docx/src/write/bidi.rs new file mode 100644 index 0000000000..abc4eae5a6 --- /dev/null +++ b/crates/docx/src/write/bidi.rs @@ -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, &'a CharProps)>, + inherits_lang: impl Fn(&CharProps) -> bool, +) -> Vec<(Range, 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, 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 = 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("")); + } +} diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 7ac86e9d53..0fd312badd 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -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" { diff --git a/crates/docx/src/write/settings_merge.rs b/crates/docx/src/write/settings_merge.rs index 84dfa25eca..b19bde32a0 100644 --- a/crates/docx/src/write/settings_merge.rs +++ b/crates/docx/src/write/settings_merge.rs @@ -21,6 +21,7 @@ const OWNED: &[&str] = &[ "w:attachedTemplate", "w:linkStyles", "w:trackRevisions", + "w:doNotTrackMoves", "w:documentProtection", "w:defaultTabStop", "w:autoHyphenation", diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 7e91b46587..ba6142fc11 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -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(); diff --git a/crates/docx/tests/bidi_runs.rs b/crates/docx/tests/bidi_runs.rs new file mode 100644 index 0000000000..067918f26b --- /dev/null +++ b/crates/docx/tests/bidi_runs.rs @@ -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("").nth(1).unwrap(); + let para = body.split("").next().unwrap(); + para.split("") + .skip(1) + .map(|r| { + let r = r.split("").next().unwrap(); + let rtl = r.contains(""); + 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("").nth(1).unwrap(); + assert_eq!(body.matches("").count(), 3); + assert_eq!(body.matches(r#""#).count(), 4, "three runs and the mark: {body}"); + assert_eq!(body.matches(r#""#).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#""#), "{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)> { + let body = xml.split("").nth(1).unwrap(); + let para = body.split("").next().unwrap(); + para.split("") + .skip(1) + .map(|r| { + let r = r.split("").next().unwrap(); + let lang = r.split(""), lang) + }) + .collect() +} + +fn langs(d: &Document) -> Vec<(bool, Option)> { + run_langs(&document_xml(&wordcraft_docx::write(d).unwrap())) +} + +fn he(rtl: bool) -> (bool, Option) { + (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)]); +} diff --git a/crates/docx/tests/complex_script_inheritance.rs b/crates/docx/tests/complex_script_inheritance.rs index 92e11ea3ce..d86e6f2a1f 100644 --- a/crates/docx/tests/complex_script_inheritance.rs +++ b/crates/docx/tests/complex_script_inheritance.rs @@ -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 { 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 { + let strip = |c: &mut CharProps| { + c.rtl = None; + c.lang_bidi = None; + }; + let mut out: Vec = 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::>().join("") + }; + let (now, change) = run.split_once(""#; + 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("").skip(1).map(|r| r.split("").next().unwrap()).collect(); + assert!(runs.len() >= 2, "the mixed run is split: {xml}"); + assert!(runs.iter().any(|r| r.contains("")), "{xml}"); + for r in runs { + assert!(r.contains(" + + Hello + + + "#; + 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())]); +} diff --git a/crates/docx/tests/settings_merge.rs b/crates/docx/tests/settings_merge.rs index 875a4f34cd..c725b5c2d3 100644 --- a/crates/docx/tests/settings_merge.rs +++ b/crates/docx/tests/settings_merge.rs @@ -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("", ""); + 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("").count(), 1, "{s}"); + assert!(pos(&s, "") < pos(&s, "") && pos(&s, "") < pos(&s, "", r#""#); + 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(""), "{s}"); + assert!(!wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap().settings.track_moves); +} diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index 7efe7b0405..1e3ac2590c 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -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 { 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 } diff --git a/crates/docx/tests/vml_shapes.rs b/crates/docx/tests/vml_shapes.rs new file mode 100644 index 0000000000..fa2a1e044a --- /dev/null +++ b/crates/docx/tests/vml_shapes.rs @@ -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 { + 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#""#; + let doc = format!( + r#"{body}"# + ); + 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#"{inner}"#) +} + +fn kinds(objs: &[InlineObject]) -> Vec { + 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#""#, + r#""#, + r#""#, + r#""#, + ))); + 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::>(), [(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#""#, + r#""#, + r#""#, + r#""#, + ))); + 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::>()), [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#""#, + r#""#, + r#""#, + r#""#, + ))); + 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#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ))); + 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#""#, + r#""#, + r#""#, + r#""#, + ))); + 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#""#.to_string(); + for _ in 0..depth { + inner = format!(r#"{inner}"#); + } + let d = read_body(&pict(&format!(r#"{inner}"#))); + // 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#""#, + r#""#, + r#""#, + r#""#, + )); + 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##""##), + pict( + r#"boxed"# + ), + pict(concat!( + r#""#, + r##""##, + r#""#, + )), + )); + 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::>()), [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::>(), [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::>().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#"{label} line {n}"#)).collect(); + format!( + r##"{paras}"## + ) + }; + // 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##""##, + r##"{}{}"##, + ), + 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 = 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 = 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##""##, + r#""#, + r##""##, + r#""#, + ))); + 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##""##, + r##""##, + ))); + 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)))); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b1ff96eaed..44340058d0 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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 diff --git a/crates/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs index 2fc5595025..1a9fe17d92 100644 --- a/crates/engine/src/cmd/column.rs +++ b/crates/engine/src/cmd/column.rs @@ -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 { diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index f445228f92..c94ccdf0f6 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -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, Vec, 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 = 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, + 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 { + 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, 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, 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, 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::>(); - 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, 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 { + 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 = 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(); diff --git a/crates/engine/src/cmd/ink_shape.rs b/crates/engine/src/cmd/ink_shape.rs index c65b37c930..1923ea2cab 100644 --- a/crates/engine/src/cmd/ink_shape.rs +++ b/crates/engine/src/cmd/ink_shape.rs @@ -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 = const { std::cell::Cell::new(0) }; +} + /// The shape `pts` draws, if it is one. pub fn recognise(pts: &[[f32; 2]]) -> Option { - // 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 { 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::>())) { + // 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 { 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 { } 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 { + 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 { + 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 { + 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::>(); + 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::>(); + 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); + } } diff --git a/crates/engine/src/cmd/inspector.rs b/crates/engine/src/cmd/inspector.rs index 894e25d9d0..8d304c9a39 100644 --- a/crates/engine/src/cmd/inspector.rs +++ b/crates/engine/src/cmd/inspector.rs @@ -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(), } } diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 80ee95e582..671778c1f8 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -23,6 +23,9 @@ pub fn specs() -> Vec { 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> { + 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::>().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::>>()?; + 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)> { + let l = s.layout(); + let labels: Vec> = 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) { + (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> = 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}"); + } + } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8881ebf7f3..7e36ba619d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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; diff --git a/crates/engine/src/cmd/moves.rs b/crates/engine/src/cmd/moves.rs index 5d0133c44e..4e23cf8ef2 100644 --- a/crates/engine/src/cmd/moves.rs +++ b/crates/engine/src/cmd/moves.rs @@ -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 { 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 { /// returns the revision for the pasted text (the destination). `None` = an ordinary insertion. pub fn take_paste(s: &mut Session, frag: &Fragment) -> Option { 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(); diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index a521fc6103..6fdfa31abe 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -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; } }) } diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index b20edbef3a..2de4769254 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -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(); diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index fdc5042e36..d77cad0e3a 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -74,11 +74,12 @@ pub fn specs() -> Vec { 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 = Default::default(); let formats: Vec = 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 = + 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::>() { + 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(); + } } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index f51ae6ce42..00cf0ed7df 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { .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 { + 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() { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 16835df92f..b8b9471db0 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -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 { + 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; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f741ef9c45..10bc18d488 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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 { 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); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 3a2fa008ad..ab7a28cd55 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -48,6 +48,7 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[ ("(tm)", "™"), ("...", "…"), ("->", "→"), + ("-->", "→"), ("<-", "←"), ("=>", "⇒"), (":)", "☺"), @@ -112,6 +113,11 @@ pub struct AutoCorrectPrefs { pub exceptions_removed: Vec, /// The user's words that keep two initial capitals (besides the built-in ones). pub initial_caps_exceptions: Vec, + /// 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 { let mut v: Vec = 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, + /// 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 { @@ -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::>(), "user": ac.entries, "exceptions": ac.exception_list(), @@ -366,7 +438,7 @@ pub fn specs() -> Vec { .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 = 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::(); 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)> { +fn case_fix(caps: &AutoCorrectPrefs, word: &str, turkic: bool) -> Option<(usize, Vec)> { if !caps.two_initial_caps && !caps.caps_lock { return None; } @@ -908,7 +1054,7 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec)> _ => return None, }; let one = |c: char| c.to_string(); - let lower = |c: char| c.to_lowercase().collect::(); + 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)> 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::(); + 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 diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d79d0a9057..ff29899133 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -70,6 +70,26 @@ pub fn specs() -> Vec { 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| { diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs index 6270d85df8..33f7c7acaa 100644 --- a/crates/engine/src/cmd/wordart.rs +++ b/crates/engine/src/cmd/wordart.rs @@ -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 { vec![ @@ -100,13 +100,14 @@ fn parse_outline(v: Option<&Value>) -> Result, 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, 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, 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, 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())), }) } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 5668a8f93b..9839501124 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -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, 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, bool, Option, - (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(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b6dd170859..065b6a0db1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); diff --git a/crates/engine/src/tests_bidi.rs b/crates/engine/src/tests_bidi.rs index 4eca74d77a..f6e50fc8dc 100644 --- a/crates/engine/src/tests_bidi.rs +++ b/crates/engine/src/tests_bidi.rs @@ -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("").nth(1).unwrap().split("").next().unwrap().to_string(); + para.split("") + .skip(1) + .map(|r| { + let r = r.split("").next().unwrap(); + let text: String = r.split("").skip(1).filter_map(|t| t.split("").next()).collect(); + (r.contains(""), 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::(), "לפני 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"); +} diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 5566c22566..e6008eaedf 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -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 { + 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)> { + let l = s.layout(); + let labels: Vec> = 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) { + (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> { + 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> = + 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"); +} diff --git a/crates/engine/src/tests_wordart.rs b/crates/engine/src/tests_wordart.rs index 9c7b63bd08..13b0b08df6 100644 --- a/crates/engine/src/tests_wordart.rs +++ b/crates/engine/src/tests_wordart.rs @@ -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); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7e6c683c75..d73b49e4d2 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -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). diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index 3eb6d57d7f..2fb09a43d8 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -422,7 +422,7 @@ fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result>)>); + +fn math_cache() -> &'static Mutex { + static CACHE: OnceLock> = 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>) { - static CACHE: OnceLock>)>>> = 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>) { } } 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>) { 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 { + 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()); diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index a2869810b2..69a7034140 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -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 { + static CACHE: OnceLock> = 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> = 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); diff --git a/crates/formats/src/mathml.rs b/crates/formats/src/mathml.rs index cf5d8fa3e4..d9974b1fc5 100644 --- a/crates/formats/src/mathml.rs +++ b/crates/formats/src/mathml.rs @@ -175,8 +175,16 @@ fn node(n: &MNode, o: &mut String, depth: usize, eq_arr: bool) { o.push_str(""); group(base, o, depth); o.push_str(""); - group(sub, o, depth); - group(sup, o, depth); + if sub.is_empty() { + o.push_str(""); + } else { + group(sub, o, depth); + } + if sup.is_empty() { + o.push_str(""); + } else { + group(sup, o, depth); + } o.push_str(""); } }, @@ -443,7 +451,8 @@ pub fn local(name: &str) -> &str { /// The linear format of a `` 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> = 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> = + 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 = 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 = 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("a⏟"), "{gc}"); let pre = to_mathml(&[MNode::Script { kind: ScriptKind::Pre, base: parse_linear("C"), sub: parse_linear("n"), sup: vec![] }], false, ""); - assert!(pre.contains(""), "{pre}"); + assert!(pre.contains("") && pre.contains(""), "{pre}"); let eq = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x&=1"), parse_linear("y&=2")] }], false, ""); assert!(eq.contains("x=1") && !eq.contains("&"), "{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 = "X12"; + let p = pasted(html); + assert!(!p.is_empty(), "should parse mmultiscripts with pre and post scripts"); + + let html_box = "x"; + let p_box = pasted(html_box); + assert!(!p_box.is_empty(), "should parse menclose"); + + let html_tbl = "ab"; + let p_tbl = pasted(html_tbl); + assert!(!p_tbl.is_empty(), "should parse mtable with columnalign"); + + let html_pad = "z"; + 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"); diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index da438604b7..7296d826da 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -344,7 +344,12 @@ impl Writer { flush(&mut body, &mut spaces, prev_space); prev_space = false; match c { - '\t' => body.push_str(""), + '\t' => { + // The reader collapses a literal space after a tab, so spaces + // that follow are written as ``. + body.push_str(""); + prev_space = true; + } '\n' => { body.push_str(""); 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:?}"); + } + } } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 8673b70649..1c8c8590cc 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/geom/src/pixels.rs b/crates/geom/src/pixels.rs new file mode 100644 index 0000000000..64258a71f8 --- /dev/null +++ b/crates/geom/src/pixels.rs @@ -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 { + (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); + } +} diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index f431444b77..a6f4eced31 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -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, /// 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, + /// How moved text is marked where it went (Moves › Moved to). + pub move_to_mark: InsertMark, + /// `None` = by author. + pub move_to_color: Option, } 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))), diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3ff6484361..6a0f150b42 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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) -> 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, 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) -> HashMap { +fn note_info(doc: &Document, hide_deleted: HideDeleted, pages: &HashMap) -> HashMap { 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) -> /// Note part ids → numbers, numbered on (see [`note_info`]). #[cfg(test)] -fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap { +fn note_numbers(doc: &Document, hide_deleted: HideDeleted) -> HashMap { note_info(doc, hide_deleted, &HashMap::new()).into_iter().map(|(id, n)| (id, n.n)).collect() } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 030bdab4e9..171f912f9f 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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 { + 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 { @@ -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, kind_override: Option) { - 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, kind_override: Option, para_text: bool) { + /// `dir`: the bidi levels `text` is shaped by (see [`Dir`]). + fn shape_with(&mut self, text: &str, base: usize, rc: &Arc, kind_override: Option, 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 = if primary_cs.is_none_or(|f| f.id() == primary.id()) { @@ -848,7 +875,7 @@ impl<'a> Builder<'a> { let mut cur: Option = 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) { + // 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 = 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 = 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> = 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> = 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 = 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 = 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) -> 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, diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index d279aaa771..8b91754eef 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -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). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 5936339ed1..e9fd94efcd 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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"); diff --git a/crates/layout/src/tests_styleref.rs b/crates/layout/src/tests_styleref.rs index cba5d9f8c5..0db962ad05 100644 --- a/crates/layout/src/tests_styleref.rs +++ b/crates/layout/src/tests_styleref.rs @@ -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)> { + 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 { + 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> { + 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 (קרפ)", "א קרפ"]); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e0719ef229..fb3812d954 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1680,10 +1680,18 @@ fn path_from(b: &kurbo::BezPath) -> Option { /// 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>) -> Option { + 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>, budget: u64) -> Option { 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>) -> Option { 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)] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1b470745c9..f45c4eb907 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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 { + fn chunk(out: &mut Vec, 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()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1126e4ee1d..df7bfa2f15 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -58,7 +58,11 @@ impl Rendered { /// PNG bytes (empty on failure, logged). pub fn to_png(&self) -> Vec { 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 { + 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 { + // 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 { + fn chunk(out: &mut Vec, 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(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 884a686218..c7cb690ff5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -45,6 +45,9 @@ pub struct CanvasState { /// revision: finding out walks every paragraph, too slow for every frame (#811). markup_facts: std::cell::Cell>, 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, + /// 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")] diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index 8880a97c3d..e5c0d22e3d 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -510,10 +510,10 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option { 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); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ca6cbba33c..ecf4597187 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 = 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() { diff --git a/crates/ui-egui/src/dialogs_controls.rs b/crates/ui-egui/src/dialogs_controls.rs index 9bdc1971b9..136d3be469 100644 --- a/crates/ui-egui/src/dialogs_controls.rs +++ b/crates/ui-egui/src/dialogs_controls.rs @@ -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), diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index a0181e1f28..61017bf011 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -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; } diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index ddd2cbb0b0..abea4ac6ed 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -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 = 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)"); + } } diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index aa946562f6..365fde41b9 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -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; } diff --git a/crates/ui-egui/src/dialogs_lists.rs b/crates/ui-egui/src/dialogs_lists.rs index 24baf130a4..ac6b8d0f3d 100644 --- a/crates/ui-egui/src/dialogs_lists.rs +++ b/crates/ui-egui/src/dialogs_lists.rs @@ -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::>(); + 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(); + } + } } diff --git a/crates/ui-egui/src/dialogs_notes.rs b/crates/ui-egui/src/dialogs_notes.rs index ae69d55927..e321555cf6 100644 --- a/crates/ui-egui/src/dialogs_notes.rs +++ b/crates/ui-egui/src/dialogs_notes.rs @@ -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 { diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 0465bee45f..d59869c020 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -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(); } diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs index d25817a681..6384e95ae3 100644 --- a/crates/ui-egui/src/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -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); } }); diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index d2a7320682..7d32dc14b1 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -439,8 +439,12 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option> 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; }); }); diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 9c326d5640..f356fb3dcc 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -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) { 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::()); 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(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6f2edba58f..0b2fcb5363 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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: Разрешаване само на този вид редактиране: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f2010d8337..ff2fc96646 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f642a74781..0d2a0a65b8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 774416b274..5b4febf5b7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 1848bc3d41..722b8ef7e6 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35d7a0fb54..471fcf8b81 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 : diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index f88e06bc73..b2378b8bca 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3344358bb7..bfc8043cb5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f7a032a071..76d4b30242 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c13afac4ca..121b355510 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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: 次の種類の編集のみを許可: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index aa60ee6d84..3ae450d973 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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: 이름: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ff590bf525..46f3ea5e30 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dc3c676d8e..2ac8e1e522 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6087bec35a..1cc8547dda 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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: Разрешить только такой способ редактирования: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 03ae3783ad..5762cf2532 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4fddcbe383..01152c33a5 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -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: Дозволи само ову врсту уређивања: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8188a494d4..785e8cb303 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e23fa4552e..f43f1b216a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -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 = 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 = 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"); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0d5a0cd591..d8e0ff1b0d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d0d7d95809..8f2e2bffd3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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: Дозволити лише такий тип редагування: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1fb99818ad..b3ca7856a5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bf4f11620c..1127294452 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -580,6 +580,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: 名称: @@ -1496,6 +1500,8 @@ Math AutoCorrect 数学符号自动更正 More >> 更多 >> Moves 移动 + Moved from: 移动自: + Moved to: 移动到: New document 新文档 No changes (read only) 不允许任何更改(只读) Only allow this kind of editing: 仅允许此类编辑: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a194a2edbb..5fd33576ed 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -569,6 +569,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: 名稱: @@ -1485,6 +1489,8 @@ Math AutoCorrect 數學自動校正 More >> 更多 >> Moves 移動 + Moved from: 移動自: + Moved to: 移動到: New document 新文件 No changes (read only) 不允許任何變更 (唯讀) Only allow this kind of editing: 僅允許此類編輯: diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index acd6b51218..3cce0a8741 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -239,12 +239,15 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { return; } let alt_codes = alt_codes_supported(ctx); + // `DeleteSurrounding` and the text replacing what it erased arrive in the same frame. + app.canvas.ime_join_text = None; for e in events { // An Alt shortcut (Alt+Shift+D, Alt+F7…) may also send its key's text; it isn't typed. match &e { egui::Event::Key { pressed: true, .. } => { app.canvas.swallow_text = false; app.canvas.alt_code_echo = None; + app.canvas.ime_join_text = None; } egui::Event::Text(_) if std::mem::take(&mut app.canvas.swallow_text) => continue, egui::Event::Text(t) => { @@ -318,14 +321,15 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { if t.chars().all(|c| !c.is_control()) && !t.is_empty() { // Typing with a text box selected types into it. crate::objects::enter_text_box(app); - let _ = app.run("text.insert", json!({"text": t})); + type_text(app, &t); } } + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => delete_surrounding(app, before_chars, after_chars), egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => app.canvas.ime_preedit = text, egui::Event::Ime(egui::ImeEvent::Commit(text)) => { app.canvas.ime_preedit.clear(); if !text.is_empty() { - let _ = app.run("text.insert", json!({"text": text})); + type_text(app, &text); } } egui::Event::Paste(t) => { @@ -534,6 +538,77 @@ fn finish_alt_code(app: &mut WordApp) { } } +/// Typed text (a key's text or an input method's commit). Text replacing what an input method's +/// `DeleteSurrounding` just erased takes the erased text's formatting, never overtypes what +/// follows, and stays in the typing undo group (#1480). +fn type_text(app: &mut WordApp, text: &str) { + if let Some(props) = app.canvas.ime_join_text.take() { + app.session.join_next_undo(); + app.session.pending = Some(props); + let overtype = std::mem::replace(&mut app.session.prefs.overtype, false); + let typed = app.run("text.insert", json!({"text": text})).is_ok(); + app.session.prefs.overtype = overtype; + if typed { + app.session.continue_typing(); + } + } else { + let _ = app.run("text.insert", json!({"text": text})); + } + note_automatic_change(app); +} + +/// AutoCorrect or AutoFormat just changed the text around the caret (its own undo step, which +/// closes the typing group). The web's text agent still holds the text as typed and counts its +/// `DeleteSurrounding` from that, so it is asked to start afresh (`canvas::ime_reset`). +fn note_automatic_change(app: &mut WordApp) { + if !app.session.typing_open() && app.session.undo_label() == Some("AutoFormat") { + app.canvas.ime_reset = true; + } +} + +/// An input method's `DeleteSurrounding`: erase `before` characters (Unicode scalar values, as +/// the input method counts them) before the caret and `after` after it, as Backspace does over a +/// selection. The web build's text agent (eframe's hidden `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -35,7 +35,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | Format | Weight | Word | WordCraft read | WordCraft write | Fidelity | Hours | How it's tested | Notes | |---|---|---|---|---|---|---|---|---| | `.docx` page background picture/texture (`v:background/v:fill`) | — | R/W | ✅ | ✅ | ~80% | — | `roundtrip.rs::page_background_picture_round_trips`, `crates/render` | Image part and `type`/`o:title` kept; tiled at 96 dpi or stretched; drawn on screen and in PDF; no Fill Effects dialog yet, gradients and patterns are not read | -| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | +| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | @@ -65,6 +65,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); a cell's own `w:vAlign w:val="top"` is kept and beats the style's (#1288); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | @@ -74,6 +76,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds` (durable ids) and `commentsExtensible` (UTC dates) read and regenerated consistently with the written paragraph ids; `people.xml` carried verbatim | | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | | Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them | +| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. `w:doNotTrackMoves` (Track moves off) round-trips; with it a tracked cut and paste records a plain deletion and insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | +| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC. Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit | @@ -223,6 +227,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-16 | trivial | Cached text fields retain result-run formatting instead of field-begin formatting. | | 2026-10-17 | trivial | Glossary document (building blocks) read into the model, kept verbatim while unchanged and written from the model once edited; split from the `w:altChunk`/sub-documents row | | 2026-10-16 | trivial | Legacy `w:outline`, `w:shadow`, `w:emboss` and `w:imprint` run effects drawn on screen and in PDFs (#2534) | +| 2026-10-15 | trivial | `w:doNotTrackMoves` read and written (Track moves off: a tracked cut and paste is a deletion and an insertion) | | 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | @@ -327,3 +332,4 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-12 | trivial | Charts of types WordCraft doesn't draw (radar, stock…) show a placeholder with their title and type (#998) | | 2026-10-13 | trivial | DOCX styles without any `w:qFormat` (LibreOffice) get Word's usual Styles gallery: matching built-in styles are marked quick and missing quick built-ins added (#1238) | | 2026-10-12 | trivial | Recipient lists: legacy .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 CSV/TXT (#778); plain-text import detects legacy encodings too | +| 2026-10-15 | trivial | DOCX: runs mixing right-to-left and left-to-right text are split by direction on save; right-to-left pieces get `w:rtl` and `w:lang w:bidi`, so Word shows the order WordCraft shows (#1543) | diff --git a/docs/gaps.md b/docs/gaps.md index 17b17cd7d0..1f70387ed1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -28,14 +28,14 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | Legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; Arial Unicode MS no longer selects monospace substitutes | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | -| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | +| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: 58 modal dialogs vs Word's ~100 (Split Cells and Convert Text to Table match Word's observed limits and defaults) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) and Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | -| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); missing: Ink to Math, two-stroke arrows and arrows with a closed triangle head, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | +| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189) | | #790 stores sources in `customXml` and reads them back; `crates/zotero` | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | @@ -91,6 +91,7 @@ The Language dialog reads effective character formatting, including document def | Date | Change | Summary | |---|---|---| | 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain | +| 2026-10-15 | trivial | #5 narrowed: Track Changes Options › Moves (Moved from / Moved to marks and colours) and Track moves (`w:doNotTrackMoves`) landed; drag-and-drop moves remain | | 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 | @@ -99,6 +100,7 @@ The Language dialog reads effective character formatting, including document def | 2026-10-16 | trivial | #7: Split Cells and Convert Text to Table dialogs (#2592), 58 modal dialogs (matches ui-parity.md) | | 2026-10-16 | trivial | #8 narrowed: Indonesian proofing (id-ID spelling from the installed Hunspell dictionary, generic checks, #498) | | 2026-10-16 | trivial | Legacy outline, shadow, emboss and imprint text effects drawn (#2534) | +| 2026-10-15 | trivial | Numbered headings (#1793): heading-linked Multilevel List presets; numbering keeps heading styles. Not a ranked gap; style gallery previews without numbers remain | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 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 | @@ -110,6 +112,7 @@ The Language dialog reads effective character formatting, including document def | 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | | 2026-10-14 | trivial | Field report: grouped `flowChartOr` omitted its crossing spokes; the preset now draws them; wider DOCX corpus work remains open | | 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | +| 2026-10-15 | trivial | #12: Ink to Shape pairs a line with a separately drawn V or closed-triangle head, and recognises one-stroke arrows with a closed triangle head | | 2026-10-13 | trivial | Gap #3: Behind Text pictures and shapes drag over text in the body and headers, selected or not; a click without movement still edits the text | | 2026-10-13 | trivial | Row 1: the watermark is saved to DOCX headers (#1290) | | 2026-10-13 | trivial | Row 1: Word's text watermark in DOCX headers is drawn (#1290) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index c2e5164d59..4b1dc03e1e 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with Microsoft Word -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** trivial (Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** trivial (Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -14,7 +14,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. |---|---|---|---|---|---|---|---| | Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 | | GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316) or on 32-bit; startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) | -| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. arrows (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) | +| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. one- and two-stroke arrows with open or closed heads (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) | | Multiple monitors, per-monitor DPI, remembered window position, a window that fits its screen | ✅ | ✅ (#38, #76, #1215) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 | | Microphone: Dictate | ✅ (cloud speech service) | ❌ | ❌ | ❌ | ❌ | 0% | 8–15 + owner (speech model choice) | | Speakers: Read Aloud | ✅ | ✅ system speech, speed, sentence skip (#190) | ✅ | 🟡 | 🟡 | 75% | 1–2 | @@ -25,6 +25,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Pen / stylus row: Ink to Shape also makes two-stroke arrows and arrows with a closed triangle head | | 2026-10-14 | trivial | Pen / stylus row corrected: ink, eraser and Ink to Shape (with arrows) work; pressure and Ink to Math missing | | 2026-10-13 | trivial | A window too big for its screen shrinks to fit when it opens (#1215): eframe sized the first window to the whole monitor, frame and taskbar not counted, so on a laptop screen its bottom, with the end of the scroll bar, was past the screen edge. A window saved that size is fitted on its next launch too | | 2026-10-12 | trivial | 32-bit Windows starts on OpenGL: DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 3eb83afad4..64a26bd186 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -69,7 +69,7 @@ font can't match Word's metrics. | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451) | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. Most other legacy compat options remain ignored | 10–20 | -| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | +| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | @@ -190,6 +190,7 @@ Theme-inherited dash styles, line caps, ink and open freeform strokes are not co | 2026-10-15 | trivial | The caret after spaces hanging past the right margin stays at the margin instead of running off the page (#2146) | | 2026-10-15 | trivial | A missing Thai document font is substituted by one Thai face for the whole run, Latin letters and digits included (#1549) | | 2026-10-15 | trivial | A list bullet no installed font can draw (a private-use code) shows the level's default bullet instead of an icon from an unrelated font (#1683) | +| 2026-10-16 | trivial | Field results (STYLEREF, REF, PAGE text…) with right-to-left text are reordered like ordinary text, so a Hebrew heading in a header reads correctly (#1670) | | 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index e87e995c77..5dea595220 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -40,6 +40,12 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp | `* ` `- ` `1. ` `1) ` `a. ` `a) ` `A. ` `i. ` `I. ` + text | bullet, dash bullet, 1. 1) a. a) A. i. I. lists | `list_autoformat_triggers` | | `> ` `-> ` `=> ` + text | bulleted list with an arrow bullet (➢ ➔ ⇨; Word takes them from a symbol font, we use the Unicode arrows) ¹ | `arrow_bullets` | | `1. ` after other text | a new list restarting at 1 (never continues an earlier list) | `typing_1_dot_starts_numbering_again` | +| Heading 1 `One`, Heading 2 `Two`, Heading 1 `Three`, the caret in `Three`, Multilevel List › the "1 / 1.1 / 1.1.1" heading list, then at the end Enter, Heading 2, `Four` Enter `Body` | One = 1, Two = 1.1, Three = 2, styles kept; the new Heading 2 is numbered 2.1 by itself (the heading styles carry the list); `Body` (Normal) stays unnumbered | `a_new_heading_after_a_heading_linked_list_is_numbered` | +| Numbering on a Heading 1 | "1. One", still Heading 1 | `numbering_a_heading_keeps_the_heading_style` | +| The "1 / 1.1 / 1.1.1" heading list with a caret or a selection | paragraphs already in a Heading style keep their style and level; every other paragraph style (Normal, Quote, Title…) becomes Heading 1. One H1 / Body / Two H2 → 1, Body H1 "2", Two H2 "2.1"; Two H2 + Three H3 with no Heading 1 above → 1.1, 1.1.1; caret in a Heading 2 → stays Heading 2 "1.1"; Normal alone → Heading 1 "1" (checked in Word for Mac, 2026-10-16) | `a_heading_preset_keeps_headings_and_makes_other_paragraphs_heading_1`, `a_heading_preset_on_a_lone_normal_paragraph_makes_it_heading_1` | +| Undo after a heading list | one step: paragraph styles and the heading styles' numbers come back | `undo_takes_a_heading_preset_back_in_one_step` | +| A paragraph whose numbering was switched off, then Heading 2 from a heading-linked list | numbered (1.1) by the style | `applying_a_numbered_heading_style_numbers_the_paragraph` | +| `1. ` typed in a Heading 1 (also `a) ` in a Heading 2, `1. ` in a Subtitle) | numbered, the style kept; only body text becomes List Paragraph (checked in Word for Mac, 2026-10-15) | `typing_1_dot_in_a_heading_keeps_the_heading_style` | | ⌘Z right after an AutoFormat | undoes only the AutoFormat (`* ` comes back as text) | `undo_right_after_autoformat_undoes_only_the_autoformat` | ## Undo @@ -59,6 +65,9 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp | Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` | | Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` | | Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` | +| Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table. A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` | +| Backspace / Delete with a table and the paragraph before or after it selected | both remove the table, whatever end the selection starts in; the text around joins. Any table the selection covers whole goes, at any nesting level | `delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table`, `delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table` | +| Backspace / Delete with whole rows or columns selected | Backspace deletes the rows or columns (Delete Rows / Delete Columns); Delete clears their cells. One selected cell is just that cell, also in a one-row or one-column table: Backspace clears it. **Differs:** in a 1×1 table, Select Cell then Backspace removes the table (WordCraft has no end-of-row mark to tell the cell from the whole table; the end-of-row caret position is still missing, #1844) | `backspace_deletes_selected_rows_and_delete_clears_them`, `backspace_deletes_selected_columns_and_delete_clears_them`, `backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it`; `delete_range` edge cases: `edit::tests::a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table` and five more (other tables, nested tables, story ends, headers and footnotes, content controls, nesting past the paragraph walk) | | Table Layout › Delete Table / Rows / Columns / Cells (tracking on) | like Word deleting a selected table: the cells' text is marked deleted and the table structure stays (WordCraft has no tracked row or column deletion, `w:trPr/w:del`, so rows and columns keep their cells too, where Word's Accept removes the rows); text you inserted yourself is just removed; Reject All restores the text. A table, row or column with no text at all (empty, or its deletion already accepted) is removed as with tracking off; one whose text is all already marked deleted is left alone with a message (accept or reject first). Delete Columns leaves a merged cell that also spans a kept column alone (it narrows, keeping its text). A content control locked against deletion, or content locked against editing, refuses the delete with tracking on or off | `tests_revision_checks::tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it`, `tracked_row_column_and_cell_deletes_mark_their_text_deleted`, `tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original`, `table_deletes_refuse_a_control_locked_against_deletion`, `tracked_delete_table_removes_a_table_with_no_live_text`, `delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone`, `tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original` | | Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` | | Unlink Fields (Ctrl+Shift+F9) (tracking on) | not tracked, like Word: the field's result stays as ordinary text. Refused in content locked against editing | `tests_revision_checks::tracked_unlink_keeps_the_result_as_plain_text`, `unlink_refuses_a_field_in_content_locked_against_editing` | @@ -84,6 +93,10 @@ Windows/Linux keys are given first, macOS second. ## AutoCorrect and AutoFormat as you type +The `-->` replacement produces `→` when completed with a space (#2223), including +at paragraph start without creating a list. It respects AutoCorrect preferences, user +entries and undo. The existing `-> ` paragraph-start arrow bullet is unchanged. + Applied when a word is finished by a space, punctuation **or Enter** (`enter_finishes_a_word_for_autocorrect`): - First letter of a sentence (paragraph or list item start, after `. ! ?`) is capitalised; not @@ -92,10 +105,37 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin `wait...`→`wait…`, smart quotes (`autocorrect_as_you_type`); a quote opens after a space, an opening bracket, a dash, `«` `‹` `„` `‚`, or the other kind of opening quote, so nested quotes curl correctly (`smart_quotes_nested_and_opening_delimiters`). + Smart quotes in Hebrew (and other right-to-left text) are chosen from the characters around + the quote in logical order, whatever the display direction: `"` after a space or at the start + is “ (U+201C) even though it shows on the right of the word, after a letter ” (U+201D); in an + acronym (`צה"ל`) it becomes ” as in Word for Mac 16.113.4, not gershayim (״); `'` after a letter + (`ג'ירפה`) is ’ as after any letter, with no geresh conversion (not yet checked in Word; `smart_quotes_in_hebrew_use_logical_order`, #1670). - Correct TWo INitial CApitals: a finished word of three or more letters with two capitals and the rest lowercase loses its second capital (`WOrd`→`Word`, `TEh`→`The`); not the exceptions (`IDs`, `CDs`, `PCs`, `TVs`, … plus the user's, `tools.autocorrect {"addInitialCapsException"}`) (`two_initial_capitals_are_corrected`). +- Reversing an automatic capital makes an exception (Word's "Automatically add words to list", + observed on the Mac, #1857): Undo of the sentence capital's own step adds the word before it + to the First Letter list (`there.`; only words ending in a full stop, the one form seen in + Word), and so does Backspacing over the capitalised word and typing it again; the word is then + not capitalised after `there.` any more. Any other Undo (of later typing, of a paste) adds + nothing, and the capital is forgotten once another Undo runs, a new document is opened, or the + undo history is cleared. Each list has its own switch + (`tools.autocorrect {"autoAddFirstLetter", "autoAddInitialCaps"}`, on by default); words over + 255 characters or with spaces are never added. Undoing a replacement (`teh`→`the`) adds + nothing: Word doesn't either. Undoing a TWo INitial CApitals fix adds the word to the INitial + CAps list (as in Word, checked on the Mac: `TWo ` typed again isn't corrected; same switch rule as First Letter). The first word of a + paragraph has no word before it, so nothing is added there; Word also left the retyped first + word ("hello") lowercase in that paragraph, which WordCraft doesn't (paragraph-level + suppression, only for the paragraph where the Undo happened: Word capitalises the first word + of a new paragraph again; a separate follow-up). Word adds nothing in bulleted-list paragraphs; not covered + (`undoing_a_sentence_capital_adds_a_first_letter_exception`, + `backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception`, + `automatic_exceptions_can_be_turned_off`, `undoing_a_replacement_adds_no_exception`, + `undoing_an_initial_caps_fix_adds_an_initial_caps_exception`, + `undoing_other_edits_after_a_capital_adds_no_exception`, + `a_full_undo_history_does_not_match_later_undos`, `a_new_document_forgets_the_capital`, + `an_overlong_word_is_not_added_as_an_exception`). - Correct accidental usage of cAPS LOCK: `wORD`→`Word` (three or more letters; not `iOS`, `mRNA`, …). Word also switches Caps Lock off; we only correct the word (`accidental_caps_lock_is_corrected`). @@ -125,6 +165,14 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin - Smileys `:)` `:-)` `:(` `:-(` `:|` → ☺ ☹ 😐, also straight after a word (`ok:)` → `ok☺`) ¹ (`smileys_are_replaced_inside_words_too`). +- Turkish, Azerbaijani and Crimean Tatar text (the word's language, else the editing language) + maps `i` → `İ` and `ı` → `I` when a sentence or day is capitalised, and `I` → `ı` when + TWo INitial CApitals / cAPS LOCK lowercase a letter (`istanbul` → `İstanbul`, `İNgilizce` → + `İngilizce`, `ılık` → `Ilık`; observed in Word for Mac with Turkish proofing). Lowercasing `İ` + gives the single letter `i` in every language, never `i` + combining dot. Other languages keep + the Unicode default (`istanbul` → `Istanbul` in English) + (`autocorrect_capitalisation_follows_turkic_case_mapping`). Format > Change Case is not yet + Turkish-aware. - Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412, `tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and @@ -162,6 +210,13 @@ Word. right), as in Word, so Tab moves to the cell on the left (the next one in reading order) and Shift+Tab back; in a left-to-right table Tab still moves right (#2634, `tab_in_a_table_inserted_in_right_to_left_text_moves_left`). +- Text typed or inserted (links, fields, symbols, pasted text with merged formatting) takes its + direction from its own letters, not from the run around the caret, keeping only that run's + bidi language: Latin typed into Hebrew from a reopened file is saved as left-to-right text, and + the saved file marks the right-to-left pieces `w:rtl` itself (#1543, + `latin_typed_into_reopened_hebrew_is_saved_left_to_right`). Known display difference: spaces, + digits and punctuation typed into reopened Hebrew text draw in the Latin font, not the + complex-script font, until the file is saved and reopened. ## Layout @@ -178,8 +233,32 @@ Word. `myanmar_typing_steps_by_syllable_and_backspaces_by_sign`, `indic_and_khmer_backspace_removes_one_character`). +## Input methods + +- An input method can ask to erase characters before and after the caret (`DeleteSurrounding`) + and send replacement text. The web build's hidden text field does this whenever its contents + change other than by adding to the end: Backspace and corrections from phone keyboards, and + the Chrome caret jump below. The desktop builds never receive it (egui-winit 0.36 doesn't + send it). WordCraft erases those characters as Backspace erases a selection, stopping at the + paragraph's ends, then types the new text. The new text keeps the erased text's formatting + and never overtypes what follows. Erasing and retyping stay in the open typing undo step, and + typing after them joins it too (#1480, + `keys::tests::delete_surrounding_from_the_web_text_agent_does_not_repeat_text`, + `delete_surrounding_erases_characters_on_both_sides_of_the_caret`, + `delete_surrounding_stays_inside_the_paragraph`, `delete_surrounding_joins_the_typing_undo_step`, + `delete_surrounding_keeps_the_replaced_texts_formatting`, + `delete_surrounding_text_does_not_overtype`, `delete_surrounding_rewrite_ends_at_a_key_press`). +- After AutoCorrect or AutoFormat changes the text as you type, the web build tells its hidden + text field to start afresh. Its later counts then can't reach past the change + (`automatic_change_asks_the_input_method_to_start_afresh`). The reset itself happens in eframe + and hasn't been checked in a browser. + ## Known gaps +- Web (#1480): Chrome has been seen moving the caret of eframe's hidden text field to its + start after the first non-Latin character. Later letters then go in before what was typed + since the field was last cleared, so `hello` then `мир` gives `риhelloм`. The text is no + longer repeated, but the cause of the caret jump hasn't been found and isn't worked around. - Word's body font Aptos isn't installed outside Office; our substitute (Carlito, else Helvetica Neue) is wider, so lines wrap earlier than in Word. Needs a metric-matched open substitute. - Moving paragraphs with Track Changes on isn't recorded as a tracked move; whole table rows @@ -207,12 +286,22 @@ Word. | 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) | | 2026-10-15 | trivial | Enter on an empty nested list item whose numbering comes from its paragraph style (List Number 2) moves it up a level instead of adding another item (#2143) | | 2026-10-18 | trivial | Smart quotes open after `«` `‹` `„` `‚` and after the other kind of opening quote, so nested quotes curl correctly | +| 2026-10-15 | minor | AutoCorrect: undoing the capital's own step (or backspacing it and retyping) adds an exception; "Automatically add words to list" switches (#1857) | +| 2026-10-16 | trivial | #1793: a heading-list preset keeps paragraphs already in a Heading style at their level and makes other paragraphs Heading 1 (rechecked in Word for Mac); undo test | +| 2026-10-16 | trivial | #1793: a heading-linked Multilevel List preset makes every selected paragraph its first level (Heading 1), checked in Word for Mac; `1. ` in a heading checked in Word; a single Heading 2 becoming Heading 1 is unchecked | +| 2026-10-15 | trivial | Numbered headings (#1793): a heading-linked Multilevel List numbers headings through their styles, so a new Heading 2 after the list is numbered by itself; Numbering and `1. ` keep a heading a heading; applying a numbered heading style numbers the paragraph | +| 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 | Input methods (#1480 review): the rewritten text keeps its formatting, doesn't overtype, keeps the typing undo group; AutoCorrect/AutoFormat reset the web text field; desktop doesn't receive `DeleteSurrounding` | +| 2026-10-15 | trivial | Input methods: `DeleteSurrounding` erases around the caret, so web typing in non-Latin text no longer repeats; Chrome's caret jump in the hidden field is listed as a known gap (#1480) | +| 2026-10-15 | minor | Backspace with a whole table, whole rows or whole columns selected deletes them; Delete clears them; a selection covering a table and the text beside it removes the table (#1844) | +| 2026-10-16 | trivial | Smart quotes in Hebrew follow logical order; ” (not gershayim) in acronyms, as Word does (#1670) | | 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) | | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067), exact matching, bounded parameters, original English and one-step undo | | 2026-10-13 | minor | Alt+numpad decimal character codes: the ANSI code page with a leading 0, the OEM page without (the system locale's on Windows: 1250–1254, 437/737/850/852/857/866), Unicode above 255 (#961) | | 2026-10-13 | trivial | Fields: text typed or pasted just after a field (a Zotero citation) goes outside it (#898) | +| 2026-10-15 | trivial | AutoCorrect: Turkish/Azerbaijani case mapping (i↔İ, ı↔I), no combining dot from İ (#1923) | | 2026-10-14 | trivial | Track Changes: a table delete over text already marked deleted changes nothing, so Reject All still restores it (#1329 review 2) | | 2026-10-14 | trivial | Track Changes: empty tables are removed under tracking; Delete Columns leaves merged cells that keep a column; repeated Change Case reads only live text (#1329 review) | | 2026-10-14 | trivial | Track Changes: table deletes mark the cells' text deleted and keep the table; Change Case is a tracked delete+insert (differs from Word's hidden formatting revision); Unlink Fields stays untracked; all three refuse locked content controls (#1329) | @@ -236,3 +325,4 @@ Word. | 2026-10-10 | trivial | Status line, revision history and cross-links added (progress-docs standard) | | 2026-10-10 | minor | Track Changes rows: tracked Enter, Backspace and Delete on paragraph marks (#244) | | 2026-10-10 | major | First version: lists, AutoCorrect and AutoFormat as you type, observed against Word (#204) | +| 2026-10-15 | trivial | Text direction: typed and inserted text takes its direction from its own letters, not the caret's run; spaces typed into reopened Hebrew draw in the Latin font until reopened (#1543) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 38f1d3cee3..81b8bb4648 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Home › Underline drop-down shows its lines, #2738; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #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 text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Home › Underline drop-down shows its lines, #2738; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #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 text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -46,8 +46,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772) | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 85% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299) | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | -| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252) | ✅ | ✅ | 90% | — | -| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — | +| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252); Interface Theme as a searchable View command (light/dark/system, toggle without a value, #1861) | ✅ | ✅ | 90% | — | | Interface size (File › Options › General: Auto, which keeps the system display scale and shows the size it draws at, or an absolute 75–300 %, so "100%" is one screen pixel per point; `ui.interfaceSize`) (#475, #822). A size from before #822 (on top of the display scale) becomes the absolute size it drew at. On the web the browser's zoom is part of the display scale, so a chosen size cancels browser zoom (Ctrl+plus/minus no longer resize the interface); Auto follows it | ✅ | system display scaling only | 100% | — | | Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 | | Full screen (#980): View › Window › Full Screen (`view.fullScreen`), F11 (⌃⌘F on macOS) toggles OS-level full screen, Escape leaves it, leaving gives back the earlier window size; the window manager's or macOS's own full screen keeps it in step. In the browser it's off (the browser's own F11) | ✅ | Word for Mac: View › Enter Full Screen (⌃⌘F); Word for Windows: Full Screen Mode hides the ribbon only | 100% | — | @@ -71,6 +70,7 @@ Find selects the Results tab, including when it prefills selected text. Editing | 2026-10-15 | trivial | Font box (ribbon and mini toolbar): a Recently Used Fonts section at the top of the list holds the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, once each, ten at most, kept between runs in `ui.json`; All Fonts follows; the sections show while nothing is typed and a query lists its matches only; Up/Down walk the recent entries then the list, Enter applies the highlighted one; a recent font no longer installed stays listed (#2068) | | 2026-10-16 | trivial | Web Layout text fills the window width and re-wraps once a resize settles or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876) | | 2026-10-18 | trivial | Different First Page: double-clicking page 1's header while editing another one goes into page 1's own; Insert › Header/Footer/Page Number and Edit Header/Footer use the header the caret's page shows (first, even or default) instead of always the default (#2826) | +| 2026-10-15 | trivial | Track Changes Options › Moves: Moved from and Moved to rows (mark and colour; green double strikethrough and double underline by default, as in Word for Mac, which has no Track moves checkbox). `review.trackingOptions {"moveFromMark", "moveFromColor", "moveToMark", "moveToColor"}`, saved per user; `"trackMoves"` sets the document's setting. Mark lists in Word for Mac's order; Deletions and Moved from gain (none), Bold, Italic and Double underline; moved text marked (none) is plain text | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-16 | trivial | Create Source: automatic Tag like Word's (`Smi20`, `Kim001`); Edit Source keeps the Tag and citations show the new Author and Year (#2094; Word for Mac 16.113.4, user-checked) | | 2026-10-18 | trivial | Split Cells merged-cell limits and equal parts, Convert Text to Table Initial column width, both from the Word check (#2592) | @@ -80,6 +80,11 @@ Find selects the Results tab, including when it prefills selected text. Editing | 2026-10-16 | trivial | Comment and revision balloons are readable in the dark theme: the card, the markup area, the author stripes and the leader lines come from theme tokens, and the text reaches 4.5:1 (#2045) | | 2026-10-15 | trivial | Layout › Columns works on the selected text, as in Word: section breaks around it, the rest of the document unchanged; More Columns… adds "Selected text" to Apply to and picks it when text is selected (#2024) | | 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) | +| 2026-10-15 | trivial | AutoCorrect Options › Exceptions: "Automatically add words to list" switches for First Letter and INitial CAps (#1857) | +| 2026-10-16 | trivial | Multilevel List heading presets: every selected paragraph takes the first level (Heading 1), as Word for Mac does; headings outside the selection keep their styles and levels (#1793) | +| 2026-10-16 | trivial | Multilevel List heading presets: selected paragraphs already in a Heading style keep their style and level, every other selected paragraph becomes Heading 1, as Word for Mac does (#1793) | +| 2026-10-15 | trivial | Multilevel List gallery: 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`; headings keep their styles and new headings are numbered (#1793). Style gallery previews don't show the numbers yet | +| 2026-10-16 | trivial | A mouse drag that edits a table (column or row border, move handle) leaves the view where it is; typing still follows the caret (#1738) | | 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% | | 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) | @@ -215,3 +220,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-12 | trivial | Recipient List dialog: Filter and Sort section (conditions joined by And/Or, three sort keys, merge order column), .xls in the picker (#778) | | 2026-10-13 | trivial | Double-clicking the top or bottom margin of a page with no header or footer creates the one that page shows and edits it, as in Word; one left empty is taken away again on leaving; `insert.editHeader`/`insert.editFooter {"page"}` for agents (#960); making one is an undo step (`insert.createHeader`/`insert.createFooter`), refused read-only, linked from the first section | | 2026-10-15 | trivial | Narrow windows: the tab strip keeps the room of Comments / Editing / Share and gives way in order (tab padding, Comments and Editing labels, Share label, plain tabs into », active and contextual labels cut with …, last the active tab into »); File never leaves the strip. Public sources say Word keeps those buttons in view and shrinks the tabs; the » menu is ours (#2026) | +| 2026-10-15 | trivial | Interface Theme as a searchable View › Dark Mode command (light/dark/system, toggles light and dark without a value), driving the same setting as Options; ribbon menu rewired to it (#1861) |