From d04d9167647c95853640adecbfb1d2bd7f0303d8 Mon Sep 17 00:00:00 2001 From: Nathanael Date: Wed, 14 Oct 2026 12:50:26 +0700 Subject: [PATCH 001/199] ribbon: remove no-op drop(run) flagged by clippy::drop_non_drop (#1431) The closure's borrow of the session already ends at its last use, and clippy 1.98 rejects dropping a non-Drop value, which stopped xtask ci. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/ribbon.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/ribbon.rs b/crates/engine/src/ribbon.rs index 341e7e76a..49cb8793d 100644 --- a/crates/engine/src/ribbon.rs +++ b/crates/engine/src/ribbon.rs @@ -462,7 +462,6 @@ mod tests { assert!(run(json!({"rename": {"tab": "Writing", "to": "insert"}})).is_err()); assert!(run(json!({"add": {"tab": "Writing", "group": "New Group", "command": "no.such"}})).is_err()); assert!(run(json!({"move": {"tab": "Nope", "by": 1}})).is_err()); - drop(run); assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); s.run("tools.customizeRibbon", &json!({"reset": true})).unwrap(); assert_eq!(s.ribbon, RibbonLayout::default()); From d8cfe59c14da23a636d6d5a53e6f74deaa1339d7 Mon Sep 17 00:00:00 2001 From: Raj Patel Date: Tue, 13 Oct 2026 23:50:31 -0600 Subject: [PATCH 002/199] DOCX: keep Word ink placed directly in a run when saving (gap #2) (#1098) --- ROADMAP.md | 4 ++- crates/docx/src/read/story.rs | 16 ++++++++++++ crates/docx/src/write/story.rs | 47 +++++++++++++++++++--------------- crates/docx/tests/fixtures.rs | 30 ++++++++++++++++++++++ docs/file-format-parity.md | 7 ++--- docs/gaps.md | 7 ++--- 6 files changed, 83 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c331514cf..09ed3938c 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-11 · **Change:** trivial (progress log: 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-12 · **Change:** trivial (direct run-level native InkML content parts survive DOCX saves; previously trivial: progress log: 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. @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -273,6 +274,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index fc6302e4b..0ece8d837 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -639,6 +639,11 @@ impl Reader<'_> { self.emit_obj(sc, pb, o, props); } } + "w:contentPart" => { + if let Some(o) = self.read_ink_content_part(k, rels) { + self.emit_obj(sc, pb, o, props); + } + } "w:footnoteReference" | "w:endnoteReference" => { let foot = k.name == "w:footnoteReference"; let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN); @@ -998,6 +1003,17 @@ impl Reader<'_> { Some(object) } + fn read_ink_content_part(&mut self, content: &El, rels: &Rels) -> Option { + let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?; + let pkg = self.pkg; + if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") { + return None; + } + let source = self.keep_embedded(content, rels, &[])?; + let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, source: Some(source), ..Default::default() }); + Some(InlineObject::Graphic { w: 0.0, h: 0.0, alt: String::new(), float: Float::default(), graphic }) + } + /// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left /// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word /// stores beside them. Built once per part and size; once the file's graphic budget is spent, diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 8d87d6166..f53d10a0e 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -535,7 +535,8 @@ impl Writer<'_> { (None, None, None) => None, }; let Some(mut root) = root else { return }; - if ink { + let direct_ink = ink && root.name == "w:contentPart"; + if ink && !direct_ink { Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); } let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root); @@ -547,26 +548,30 @@ impl Writer<'_> { self.rev_open(w, props); w.open("w:r", &[]); self.rpr(w, props); - if fallback.is_some() { - w.open("mc:AlternateContent", &[]); - w.open("mc:Choice", &[("Requires", "wpi")]); - } - w.open("w:drawing", &[]); - let docpr = self.next_docpr(); - let name = match graphic.kind { - wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), - wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), - wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), - }; - self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt); - w.empty("wp:cNvGraphicFramePr", &[]); - w.raw(&native.unwrap_or(&root).to_xml()); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - if let Some(fallback) = fallback { - w.close("mc:Choice"); - w.raw(&fallback.to_xml()); - w.close("mc:AlternateContent"); + if direct_ink { + w.raw(&root.to_xml()); + } else { + if fallback.is_some() { + w.open("mc:AlternateContent", &[]); + w.open("mc:Choice", &[("Requires", "wpi")]); + } + w.open("w:drawing", &[]); + let docpr = self.next_docpr(); + let name = match graphic.kind { + wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), + wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), + wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), + }; + self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt); + w.empty("wp:cNvGraphicFramePr", &[]); + w.raw(&native.unwrap_or(&root).to_xml()); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + if let Some(fallback) = fallback { + w.close("mc:Choice"); + w.raw(&fallback.to_xml()); + w.close("mc:AlternateContent"); + } } w.close("w:r"); self.rev_close(w, props); diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 689f37dc4..c4e5aee99 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1699,6 +1699,36 @@ fn native_ink_survives_unrelated_edit_and_two_saves() { } } +#[test] +fn direct_native_ink_content_part_survives_unrelated_edit_and_two_saves() { + let body = r#"Original"#; + let mut d = wordcraft_docx::read(&docx( + body, + &[("rInk", "customXml", "ink/ink1.xml")], + &[("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)], + )) + .unwrap(); + assert_eq!(graphics(&d).len(), 1, "direct native ink must remain a model object"); + let p = std::sync::Arc::make_mut(&mut d.body[0]); + let Block::Para(p) = p else { panic!() }; + p.delete(0, 8).unwrap(); + p.insert_text(0, "Edited", &Default::default()).unwrap(); + for _ in 0..2 { + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + let body = text(&files, "word/document.xml"); + assert!(body.contains(" **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** 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-12 · **Change:** trivial (direct run-level InkML content parts now survive DOCX saves; 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; @@ -68,7 +68,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | | Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | -| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; run-level `mc:AlternateContent` keeps the native branch and VML picture fallback. Shows the preview or a missing-picture box. Repeated saves, copies, resize, rotation and namespace aliases tested with original fixtures. No native stroke rendering, eraser, conversion or replay; direct run-level content parts and ink nested in groups/canvases remain unsupported | +| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. No native stroke rendering, eraser, conversion or replay; ink nested in groups/canvases remains unsupported | | 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | | Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | @@ -134,7 +134,7 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi 1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with Word (local only: Word output can't be committed; keep a manifest and our own renders). 2. **Preserve what we can't render**: remaining ink forms and unsupported graphics survive a round trip - (keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319); drawing-level native ink now keeps its InkML and optional picture fallback. + (keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319); drawing-level and direct run-level native ink now keep their InkML and optional picture fallback. 3. Move tracking (`w:moveFrom`/`w:moveTo`) read and written (formatting revisions: #41; content controls: #381). 4. Encrypted `.docx` opens with a password. @@ -163,6 +163,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | diff --git a/docs/gaps.md b/docs/gaps.md index 04a985cc9..dcc4c4325 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-11 · **Change:** 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-12 · **Change:** trivial (direct run-level native InkML content parts now survive DOCX saves; 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. @@ -16,7 +16,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | # | Gap | B | Evidence | Impact | Hours | Doc | |---|---|---|---|---|---|---| | 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-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 is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain 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) | +| 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) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (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 (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | 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 ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-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. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | @@ -26,7 +26,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), 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 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~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); missing: Ink to Math, arrows in Ink to Shape, pressure, rendering/editing Word's own ink (drawing-level `w14:contentPart`/InkML now preserved on DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 9/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); missing: Ink to Math, arrows in Ink to Shape, 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 9/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, 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**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | @@ -73,6 +73,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | | 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps | | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | | 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells (into or out of a table, whole rows); removed from the UI/UX gaps | From 46d5554f39dc708d8f1c555059965b10206ea391 Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Wed, 14 Oct 2026 07:50:37 +0200 Subject: [PATCH 003/199] Props colours take RRGGBB too: shading, underlineColor, border colour (#1089) Rgb now reads "RRGGBB" / "#RRGGBB" as well as [r, g, b], so every colour in CharProps and ParaProps takes the hex that format.color, para.borders and table.shading take, and {"Rgb": "1B1611"} works for color. It is still written as [r, g, b], so saved documents and settings are unchanged. Fixes #865 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/doc/src/props.rs | 48 ++++++++++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 35 ++++++++++++++++++++++++++- docs/mcp.md | 16 ++++++------- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 768625d4c..f963c1433 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -6,9 +6,38 @@ use serde::{Deserialize, Serialize}; /// An sRGB colour. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)] pub struct Rgb(pub u8, pub u8, pub u8); +impl<'de> Deserialize<'de> for Rgb { + /// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in + /// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865). + fn deserialize>(d: D) -> Result { + use serde::de::{Error, SeqAccess, Unexpected, Visitor}; + struct V; + impl<'de> Visitor<'de> for V { + type Value = Rgb; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`") + } + fn visit_str(self, s: &str) -> Result { + Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self)) + } + fn visit_seq>(self, mut a: A) -> Result { + let mut c = [0u8; 3]; + for (i, x) in c.iter_mut().enumerate() { + *x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?; + } + if a.next_element::()?.is_some() { + return Err(A::Error::invalid_length(4, &self)); + } + Ok(Rgb(c[0], c[1], c[2])) + } + } + d.deserialize_any(V) + } +} + impl Rgb { pub const BLACK: Rgb = Rgb(0, 0, 0); pub const WHITE: Rgb = Rgb(255, 255, 255); @@ -1168,13 +1197,28 @@ mod tests { assert_eq!(serde_json::to_string(&c).unwrap(), j, "written as before"); assert_eq!(read(j).unwrap(), c); } - for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B1611"}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] { + for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B16"}"#, r#"{"Rgb":[1,2]}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] { assert!(read(bad).is_err(), "{bad}"); } + assert_eq!(read(r#"{"Rgb":"1B1611"}"#).unwrap(), ink); let p: CharProps = serde_json::from_value(serde_json::json!({"color": "#1B1611", "bold": true})).unwrap(); assert_eq!((p.color, p.bold), (Some(ink), Some(true))); } + /// #865: an [`Rgb`] reads `RRGGBB` / `#RRGGBB` as well as the `[r, g, b]` it still writes. + #[test] + fn rgb_reads_hex_and_keeps_its_serialized_form() { + let read = |j: &str| serde_json::from_str::(j); + let ink = Rgb(0x1B, 0x16, 0x11); + assert_eq!(serde_json::to_string(&ink).unwrap(), "[27,22,17]", "written as before"); + for j in ["[27,22,17]", r#""1B1611""#, r##""#1b1611""##] { + assert_eq!(read(j).unwrap(), ink, "{j}"); + } + for bad in ["[27,22]", "[27,22,17,0]", "[27,22,300]", "[-1,0,0]", r#""auto""#, r#""1B161""#, r#""""#, "7", "null", r#"{"r":1}"#] { + assert!(read(bad).is_err(), "{bad}"); + } + } + #[test] fn props_serde_skip_none() { let p = CharProps { bold: Some(true), ..Default::default() }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6ed7b0d2c..02c5b5d80 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2561,11 +2561,44 @@ fn modify_style_takes_hex_colours_like_format_color() { assert_eq!(color(&s), Some(red)); assert_eq!(s.doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).color, red); - for bad in [json!("1B16"), json!("not a colour"), json!({"Rgb": "1B1611"}), json!(7)] { + for bad in [json!("1B16"), json!("not a colour"), json!({"Rgb": "1B16"}), json!(7)] { let r = modify(&mut s, bad.clone()); assert!(matches!(r, Err(crate::CmdError::Params(_))), "{bad}: {r:?}"); assert_eq!(color(&s), Some(red), "{bad} leaves the style alone"); } + modify(&mut s, json!({"Rgb": "1B1611"})).unwrap(); + assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))), "the hex string also reads inside {{\"Rgb\": …}}"); +} + +/// #865: the other colours in CharProps and ParaProps (`underlineColor`, `shading`, a border's +/// `color`) take `RRGGBB` / `#RRGGBB` like `para.borders` and `table.shading`, and still `[r, g, b]`. +#[test] +fn props_colours_take_hex_strings() { + use wordcraft_doc::{Pos, Rgb}; + let ink = Rgb(0x1B, 0x16, 0x11); + let mut s = s(); + run( + &mut s, + "styles.modify", + json!({"style": "Heading1", + "chr": {"underlineColor": "1B1611", "shading": "#1b1611", "border": {"style": "single", "width": 1, "color": "1B1611"}}, + "para": {"shading": "1B1611", "borders": {"bottom": {"style": "single", "width": 1, "color": "#1B1611"}}}}), + ); + let st = s.doc.styles.get("Heading1").unwrap(); + assert_eq!((st.chr.underline_color, st.chr.shading, st.chr.border.and_then(|b| b.color)), (Some(ink), Some(ink), Some(ink))); + assert_eq!((st.para.shading, st.para.borders.and_then(|b| b.bottom).and_then(|b| b.color)), (Some(ink), Some(ink))); + + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "para.set", json!({"props": {"shading": "C00000"}})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.shading, Some(Rgb(0xC0, 0, 0))); + run(&mut s, "para.set", json!({"props": {"shading": [1, 2, 3]}})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.shading, Some(Rgb(1, 2, 3)), "[r, g, b] still reads"); + + for bad in [json!("C0000"), json!("auto"), json!([1, 2]), json!([1, 2, 300]), json!(7)] { + let r = s.run("para.set", &json!({"props": {"shading": bad}})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "{bad}: {r:?}"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.shading, Some(Rgb(1, 2, 3)), "{bad} changes nothing"); + } } /// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2 diff --git a/docs/mcp.md b/docs/mcp.md index ae53a78a8..ed539f138 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -104,12 +104,12 @@ The acceptance test `crates/mcp/src/tests.rs` writes a formatted document using `styles.modify {"style": …, "chr"?: CharProps, "para"?: ParaProps}` and the `para` of `table.newStyle` / `table.modifyStyle` take these two objects. Every field is optional: a field left out keeps what the style chain gives. A field name the object doesn't know is ignored, not -refused, so check the spelling. Lengths are points; `[r, g, b]` is a colour as three numbers -0–255. +refused, so check the spelling. Lengths are points. A colour is a hex string `"RRGGBB"` or +`"#RRGGBB"`, or `[r, g, b]` (three numbers 0–255); it reads back as `[r, g, b]` (#865). ```json {"command": "styles.modify", "params": {"style": "Heading1", - "chr": {"font": "Georgia", "size": 18, "bold": true, "color": {"Rgb": [27, 22, 17]}}, + "chr": {"font": "Georgia", "size": 18, "bold": true, "color": "1B1611"}, "para": {"spaceBefore": 12, "lineSpacing": {"rule": "multiple", "value": 1.15}, "keepNext": true}}} ``` @@ -122,10 +122,10 @@ refused, so check the spelling. Lengths are points; `[r, g, b]` is a colour as t | `size` | number | points | | `bold`, `italic`, `strike`, `doubleStrike`, `caps`, `smallCaps`, `hidden`, `outline`, `shadow`, `emboss`, `engrave`, `noProof` | bool | | | `underline` | string | `none`, `single`, `words`, `double`, `thick`, `dotted`, `dash`, `dotDash`, `dotDotDash`, `wave`, `doubleWave` | -| `underlineColor` | `[r, g, b]` | | -| `color` | `"Auto"`, a hex string `"RRGGBB"` or `"#RRGGBB"`, or `{"Rgb": [r, g, b]}` | `Auto` (also `"auto"`) is black on light backgrounds, white on dark; read back as `"Auto"` or `{"Rgb": …}` (#865) | +| `underlineColor` | colour | | +| `color` | `"Auto"`, a colour, or `{"Rgb": colour}` | `Auto` (also `"auto"`) is black on light backgrounds, white on dark; read back as `"Auto"` or `{"Rgb": …}` (#865) | | `highlight` | string | `none`, `yellow`, `brightGreen`, `turquoise`, `pink`, `blue`, `red`, `darkBlue`, `teal`, `green`, `violet`, `darkRed`, `darkYellow`, `gray50`, `gray25`, `black`, `white` | -| `shading` | `[r, g, b]` | background fill | +| `shading` | colour | background fill | | `border` | Border | character border; `"style": "none"` is explicitly no border | | `vertAlign` | string | `baseline`, `superscript`, `subscript` | | `spacing` | number | character spacing, points: positive expands, negative condenses | @@ -146,7 +146,7 @@ refused, so check the spelling. Lengths are points; `[r, g, b]` is a colour as t `format.set` also applies `size`, `bold` and `italic` to `sizeCs`, `boldCs` and `italicCs` when those aren't given; `styles.modify` sets only what it is given. -A **Border** is `{"style": …, "width": pt, "color": [r, g, b] | null, "space": pt}` (`space` is +A **Border** is `{"style": …, "width": pt, "color": colour | null, "space": pt}` (`space` is the distance from the text). Missing fields are `none`, 0, no colour and 0, so a border needs a `width` above 0 to show. `style` is one of `none`, `single`, `double`, `dotted`, `dashed`, `thick`, `triple`, `dotDash`, `wave`. @@ -168,7 +168,7 @@ the distance from the text). Missing fields are `none`, 0, no colour and 0, so a | `outlineLevel` | integer | 0–8 = Level 1–9, 9 = body text | | `numbering` | object | `{"num": n, "level": n}`, both required; `level` counts from 0; `num` 0 removes numbering a style gives | | `tabs` | array | `[{"pos": pt, "align"?: "left\|center\|right\|decimal\|bar\|clear", "leader"?: "none\|dot\|hyphen\|underscore\|middleDot"}]`; replaces the inherited stops, `clear` removes an inherited one at `pos` | -| `shading` | `[r, g, b]` | background fill | +| `shading` | colour | background fill | | `borders` | object | `{"top", "left", "bottom", "right", "between"}`, each a Border (`between`: between paragraphs; `inside_v` is used by tables) | | `suppressHyphens`, `suppressLineNumbers` | bool | | | `bidi` | bool | right-to-left paragraph | From f322c60bdf81932b89aad82bf9b750f26839fbe2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:50:42 +0200 Subject: [PATCH 004/199] wordcraft-cli text keeps bullet and number labels on list items (#1080) (#1094) --- apps/wordcraft-cli/src/main.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index a0a2d5f23..e0d80fd28 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -50,6 +50,12 @@ fn open(path: &str) -> Result { Ok(s) } +/// The body text for `text`: the plain-text export, so list items keep their bullet or number label. +fn listed_text(doc: &wordcraft_doc::Document) -> Result { + let bytes = wordcraft_engine::io::save_bytes("out.txt", doc)?; + Ok(String::from_utf8_lossy(&bytes).replace("\r\n", "\n")) +} + /// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`). fn set_author(s: &mut Session, name: &str) -> Result<(), String> { s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}")) @@ -201,7 +207,8 @@ fn run(args: &[String]) -> Result<(), Failure> { "text" => { validate_options("text", &rest, NO_OPTIONS)?; let s = open(&pos(0)?)?; - write_stdout(|o| writeln!(o, "{}", s.doc.plain_text(wordcraft_doc::StoryRef::Body))) + let text = listed_text(&s.doc)?; + write_stdout(|o| writeln!(o, "{text}")) } "inspect" => { validate_options("inspect", &rest, NO_OPTIONS)?; @@ -459,6 +466,12 @@ mod tests { assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into())); } + #[test] + fn text_keeps_bullet_and_number_labels() { + let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap(); + assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond"); + } + #[test] fn author_flag_names_the_headless_session() { assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok()); From 09b8b436cc2b221e693a383609dbae80290dcdd7 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 07:52:42 +0200 Subject: [PATCH 005/199] Save as Word 2003 XML Document (#1081) (#1088) Write WordprocessingML 2003 by rewriting the DOCX writer's package into the 2003 vocabulary; file.save/saveAs take format wordml2003, File > Export lists the format, CLI convert --format wordml2003, a plain save over a 2003 file keeps it. Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + apps/wordcraft-cli/src/main.rs | 25 +- crates/docx/src/lib.rs | 2 + crates/docx/src/wordml2003.rs | 2 +- crates/docx/src/wordml2003_write.rs | 921 ++++++++++++++++++++++++++ crates/docx/tests/wordml2003_write.rs | 181 +++++ crates/engine/src/cmd/file.rs | 47 +- crates/engine/src/io.rs | 3 + crates/engine/src/io_ext.rs | 1 + crates/engine/src/tests.rs | 27 + crates/ui-egui/src/file_dialogs.rs | 11 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr-latn.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/print_page.rs | 4 + docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 32 files changed, 1236 insertions(+), 17 deletions(-) create mode 100644 crates/docx/src/wordml2003_write.rs create mode 100644 crates/docx/tests/wordml2003_write.rs diff --git a/ROADMAP.md b/ROADMAP.md index 09ed3938c..fc8a63689 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet | | 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index e0d80fd28..0f3dfec38 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -14,8 +14,10 @@ const USAGE: &str = "\ wordcraft-cli — WordCraft from the command line USAGE: - wordcraft-cli convert [--markup] - convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png); + wordcraft-cli convert [--markup] [--format wordml2003|flatopc] + convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png, + xml); an .xml file is a Word XML Document (Flat OPC), or with + --format wordml2003 a Word 2003 XML Document; --markup (Print Markup): comments beside each page and tracked changes in pdf/png, a comment list in html/md/txt wordcraft-cli info pages, words, paragraphs, properties (JSON) @@ -111,7 +113,7 @@ fn automation_roots( } const NO_OPTIONS: &[OptionSpec] = &[]; -const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }]; +const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }]; const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }]; const RUN_OPTIONS: &[OptionSpec] = &[ OptionSpec { name: "--file", takes_value: true }, @@ -186,13 +188,26 @@ fn run(args: &[String]) -> Result<(), Failure> { match cmd { "convert" => { validate_options("convert", &rest, CONVERT_OPTIONS)?; - let (i, o) = (pos(0)?, pos(1)?); + // `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path. + let format = arg_value(&rest, "--format"); + let paths: Vec<&String> = rest + .iter() + .enumerate() + .filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format"))) + .map(|(_, a)| a) + .collect(); + let missing = || format!("missing argument\n\n{USAGE}"); + let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string()); let markup = rest.iter().any(|a| a == "--markup"); let mut s = open(&i)?; let r = if o.to_ascii_lowercase().ends_with(".png") { s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup})) } else { - s.run("file.save", &json!({"path": o, "markup": markup})) + let mut params = json!({"path": o, "markup": markup}); + if let Some(f) = format { + params["format"] = json!(f); + } + s.run("file.save", ¶ms) }; r.map_err(|e| e.to_string())?; eprintln!("wrote {o}"); diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index e5895d32f..5b6ac4296 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -16,6 +16,7 @@ mod read; mod smart_art; mod units; mod wordml2003; +mod wordml2003_write; mod write; mod xml; @@ -27,6 +28,7 @@ pub use crypt::{ pub use flat::{XmlKind, sniff_xml, write as write_flat}; pub use read::read; pub use smart_art::smart_art_items; +pub use wordml2003_write::write as write_wordml2003; pub use write::{write, write_as}; /// Largest `.xml` file read as a Word XML document. diff --git a/crates/docx/src/wordml2003.rs b/crates/docx/src/wordml2003.rs index 31de86769..e876ef9f5 100644 --- a/crates/docx/src/wordml2003.rs +++ b/crates/docx/src/wordml2003.rs @@ -1,5 +1,5 @@ //! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office -//! 2003 XML Reference Schemas). Import only. +//! 2003 XML Reference Schemas). Import; the writer is `wordml2003_write`. //! //! ECMA-376 kept most of the 2003 vocabulary (`w:p`, `w:r`, `w:pPr`, `w:rPr`, `w:tbl`, //! `w:sectPr`, `w:styles`…), so the file is opened by rewriting it as the package it corresponds diff --git a/crates/docx/src/wordml2003_write.rs b/crates/docx/src/wordml2003_write.rs new file mode 100644 index 000000000..280d0e161 --- /dev/null +++ b/crates/docx/src/wordml2003_write.rs @@ -0,0 +1,921 @@ +//! Word 2003 XML Document writer (WordprocessingML 2003, root `w:wordDocument`, from the public +//! Office 2003 XML Reference Schemas). +//! +//! The mirror image of the reader in `wordml2003`: the DOCX writer produces the package, and the +//! package is rewritten into the 2003 vocabulary. ECMA-376 kept most of the 2003 element names +//! (`w:p`, `w:r`, `w:rPr`, `w:tbl`, `w:sectPr`…), so the body is mostly renamed (`w:szCs` → +//! `w:sz-cs`, `w:eastAsia` → `w:fareast`, `w:vMerge` → `w:vmerge`), and what 2003 keeps inline is +//! moved back inline: hyperlink targets (`w:hlink`), footnotes and endnotes (inside their +//! reference run), headers and footers (inside `w:sectPr`), comments, bookmarks and tracked +//! changes (`aml:annotation`), lists (`w:listPr` and a `w:lists` block), pictures (`w:pict` with +//! `w:binData` in base64) and the core properties (`o:DocumentProperties`). +//! +//! What the 2003 format cannot carry is dropped or degraded, never an error: drawings other than +//! pictures (shapes, text boxes, groups, charts, SmartArt, ink), picture crop, rotation and +//! floating position (pictures become inline), content controls (their content stays), math, +//! revisions of formatting, theme colours and fonts, custom XML, macros, embedded objects and +//! `docDefaults` other than fonts (folded into the Normal style). Elements outside the 2003 +//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the +//! parser's depth limit, pictures and stories are capped in number. + +use std::collections::{BTreeMap, HashMap}; + +use base64::Engine as _; +use wordcraft_doc::Document; + +use crate::flat::W2003_NS; +use crate::package::{Package, Rels, rt}; +use crate::xml::{El, MAX_DEPTH, Node}; +use crate::{DocxError, Flavor}; + +const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint"; +const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core"; +const VML_NS: &str = "urn:schemas-microsoft-com:vml"; +const OFFICE_NS: &str = "urn:schemas-microsoft-com:office:office"; +const W10_NS: &str = "urn:schemas-microsoft-com:office:word"; + +/// Most pictures written (the reader takes as many). +const MAX_MEDIA: usize = 4096; +/// Most header, footer, footnote, endnote and comment stories written. +const MAX_STORIES: usize = 50_000; +/// A picture's longest side, points (a corrupt extent must not give a huge frame). +const MAX_PICT_PT: f64 = 31_680.0; + +/// Write `doc` as a Word 2003 XML Document. +pub fn write(doc: &Document) -> Result, DocxError> { + let parts: BTreeMap> = crate::write::write_parts(doc, Flavor::Document)?.into_iter().collect(); + let pkg = Package::from_parts(parts); + let doc_part = pkg + .rels("_rels/.rels") + .by_type(rt::OFFICE_DOC) + .map(|r| r.target.clone()) + .filter(|t| pkg.get(t).is_some()) + .unwrap_or_else(|| "word/document.xml".to_string()); + let mut c = Conv::new(&pkg, &doc_part); + + let body = match pkg.xml(&doc_part)? { + Some(root) => { + let rels = c.doc_rels.clone(); + match root.child("w:body") { + Some(b) => c.kids(b, &rels, "w:body", 1), + None => Vec::new(), + } + } + None => Vec::new(), + }; + + let mut kids: Vec = vec![Node::El(core_props(doc))]; + if let Some(f) = c.default_fonts() { + kids.push(Node::El(f)); + } + if let Some(l) = c.lists() { + kids.push(Node::El(l)); + } + kids.push(Node::El(c.styles())); + kids.push(Node::El(plain( + "w:docPr", + vec![Node::El(attr_el("w:view", &[("w:val", "print")])), Node::El(attr_el("w:zoom", &[("w:percent", "100")]))], + ))); + kids.push(Node::El(plain("w:body", vec![Node::El(plain("wx:sect", body))]))); + + let attrs = [ + ("xmlns:w", W2003_NS), + ("xmlns:wx", AUX_NS), + ("xmlns:o", OFFICE_NS), + ("xmlns:v", VML_NS), + ("xmlns:w10", W10_NS), + ("xmlns:aml", AML_NS), + ("w:macrosPresent", "no"), + ("w:embeddedObjPresent", "no"), + ("w:ocxPresent", "no"), + ("xml:space", "preserve"), + ]; + let root = El { name: "w:wordDocument".into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids }; + let mut s = String::from("\n\n"); + s.push_str(&root.to_xml()); + Ok(s.into_bytes()) +} + +fn plain(name: &str, kids: Vec) -> El { + El { name: name.into(), attrs: Vec::new(), kids } +} + +fn attr_el(name: &str, attrs: &[(&str, &str)]) -> El { + El { name: name.into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids: Vec::new() } +} + +fn text_el(name: &str, text: &str) -> El { + El { name: name.into(), attrs: Vec::new(), kids: vec![Node::Text(text.to_string())] } +} + +/// `o:DocumentProperties` from the core properties. +fn core_props(doc: &Document) -> El { + let c = &doc.core; + let mut kids = Vec::new(); + let revision = c.revision.to_string(); + for (name, v) in [ + ("o:Title", c.title.as_str()), + ("o:Subject", c.subject.as_str()), + ("o:Author", c.creator.as_str()), + ("o:Keywords", c.keywords.as_str()), + ("o:Description", c.description.as_str()), + ("o:LastAuthor", c.last_modified_by.as_str()), + ("o:Revision", if c.revision > 0 { revision.as_str() } else { "" }), + ("o:Created", c.created.as_str()), + ("o:LastSaved", c.modified.as_str()), + ("o:Category", c.category.as_str()), + ] { + if !v.trim().is_empty() { + kids.push(Node::El(text_el(name, v.trim()))); + } + } + plain("o:DocumentProperties", kids) +} + +struct Conv<'a> { + pkg: &'a Package, + doc_rels: Rels, + footnotes: HashMap, + endnotes: HashMap, + comments: HashMap, + foot_rels: Rels, + end_rels: Rels, + comment_rels: Rels, + styles_part: Option, + numbering_part: Option, + /// Picture part path → `wordml://` name, for the pictures already given a `w:binData`. + media: HashMap, + /// `w:binData` elements waiting for the `w:pict` being written. + bins: Vec, + /// Annotation ids: (kind, original id) → the id written (one number space, as in 2003). + ann_ids: HashMap<(char, String), i64>, + next_ann: i64, + next_shape: i64, + stories: usize, + in_note: bool, +} + +impl<'a> Conv<'a> { + fn new(pkg: &'a Package, doc_part: &str) -> Conv<'a> { + let doc_rels = pkg.rels(doc_part); + let part_of = |kind: &str| doc_rels.by_type(kind).map(|r| r.target.clone()); + let notes = |kind: &str, local: &str| -> (HashMap, Rels) { + let Some(path) = part_of(kind) else { return (HashMap::new(), Rels::default()) }; + let map = pkg + .xml(&path) + .ok() + .flatten() + .map(|root| { + root.els() + .filter(|e| e.name == local) + .filter(|e| e.attr("w:type").is_none()) + .filter_map(|e| e.attr("w:id").map(|id| (id.to_string(), e.clone()))) + .collect() + }) + .unwrap_or_default(); + (map, pkg.rels(&path)) + }; + let (footnotes, foot_rels) = notes(rt::FOOTNOTES, "w:footnote"); + let (endnotes, end_rels) = notes(rt::ENDNOTES, "w:endnote"); + let (comments, comment_rels) = notes(rt::COMMENTS, "w:comment"); + let load = |kind: &str| part_of(kind).and_then(|p| pkg.xml(&p).ok().flatten()); + Conv { + pkg, + styles_part: load(rt::STYLES), + numbering_part: load(rt::NUMBERING), + doc_rels, + footnotes, + endnotes, + comments, + foot_rels, + end_rels, + comment_rels, + media: HashMap::new(), + bins: Vec::new(), + ann_ids: HashMap::new(), + next_ann: 0, + next_shape: 1025, + stories: 0, + in_note: false, + } + } + + /// The annotation id for `(kind, id)`: the same each time it is asked. + fn ann_id(&mut self, kind: char, id: &str) -> i64 { + if let Some(n) = self.ann_ids.get(&(kind, id.to_string())) { + return *n; + } + let n = self.next_ann; + self.next_ann = self.next_ann.saturating_add(1); + self.ann_ids.insert((kind, id.to_string()), n); + n + } + + fn kids(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec { + let mut out = Vec::with_capacity(e.kids.len()); + for n in &e.kids { + match n { + Node::Text(t) => out.push(Node::Text(t.clone())), + Node::El(k) => out.extend(self.node(k, rels, parent, depth + 1)), + } + } + out + } + + /// `e` rewritten for 2003: nothing (dropped), its content (unwrapped) or one element. + fn node(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec { + if depth > MAX_DEPTH { + return Vec::new(); + } + let name = e.name.as_str(); + match name { + // Content controls: the content stays, the control goes. + "w:sdt" => return e.child("w:sdtContent").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(), + "w:drawing" => return self.picture(e, rels), + // A VML fallback is kept for what the modern drawing could not be. + "mc:AlternateContent" => return e.child("mc:Fallback").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(), + "w:object" | "w:txbxContent" => return Vec::new(), + "w:footnoteReference" | "w:endnoteReference" => return self.note(e, depth), + "w:commentReference" => return self.comment(e, depth), + "w:commentRangeStart" | "w:commentRangeEnd" | "w:bookmarkStart" | "w:bookmarkEnd" => return self.range_marker(e), + "w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => { + // Paragraph-mark and row revisions sit in property blocks: no 2003 form. + if matches!(parent, "w:rPr" | "w:trPr") { + return Vec::new(); + } + return self.revision(e, rels, parent, depth); + } + "w:hyperlink" => return vec![Node::El(self.hyperlink(e, rels, depth))], + "w:numPr" if parent == "w:pPr" => return vec![Node::El(list_pr(e))], + "w:numFmt" if parent == "w:lvl" => return vec![Node::El(nfc(e))], + "w:sectPr" => return vec![Node::El(self.section(e, rels, depth))], + "w:pict" => return vec![Node::El(self.vml_pict(e, rels, depth))], + "w:tblLook" => { + return match e.attr("w:val") { + Some(v) => vec![Node::El(attr_el("w:tblLook", &[("w:val", v)]))], + None => Vec::new(), + }; + } + _ => {} + } + if !allowed(name) { + return Vec::new(); + } + let mut el = El { name: rename(name), attrs: attrs(e), kids: Vec::new() }; + if name == "v:imagedata" { + let id = e.attr("r:id").or_else(|| e.attr("r:pict")).map(str::to_string); + if let Some(src) = id.and_then(|id| rels.by_id(&id).map(|r| r.target.clone())).and_then(|t| self.bin_for(&t)) { + el.attrs.retain(|(k, _)| k != "src"); + el.attrs.push(("src".into(), src)); + } else { + return Vec::new(); + } + } + el.kids = self.kids(e, rels, name, depth); + vec![Node::El(el)] + } + + /// A `wordml://` name for the picture at package path `target`, queueing its `w:binData` the + /// first time. `None` for a format 2003 XML pictures don't hold, or past the cap. + fn bin_for(&mut self, target: &str) -> Option { + if let Some(n) = self.media.get(target) { + return Some(n.clone()); + } + let ext = target.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; + if !matches!(ext.as_str(), "png" | "jpeg" | "jpg" | "gif" | "bmp" | "emf" | "wmf" | "tif" | "tiff") || self.media.len() >= MAX_MEDIA { + return None; + } + let bytes = self.pkg.get(target)?; + let name = format!("wordml://0300{:04}.{ext}", self.media.len() + 1); + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + let bin = El { + name: "w:binData".into(), + attrs: vec![("w:name".into(), name.clone()), ("xml:space".into(), "preserve".into())], + kids: vec![Node::Text(b64)], + }; + self.bins.push(bin); + self.media.insert(target.to_string(), name.clone()); + Some(name) + } + + /// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped. + fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec { + let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() }; + let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() }; + let Some(src) = self.bin_for(&target) else { return Vec::new() }; + let pt = |k: &str| { + let emu = drawing.find("wp:extent").and_then(|x| x.attr(k)).and_then(|v| v.trim().parse::().ok()).unwrap_or(0.0); + (if emu.is_finite() { emu / 12700.0 } else { 0.0 }).clamp(0.0, MAX_PICT_PT) + }; + let style = format!("width:{}pt;height:{}pt", trim_num(pt("cx")), trim_num(pt("cy"))); + let alt = drawing.find("wp:docPr").and_then(|d| d.attr("descr")).unwrap_or("").to_string(); + let shape_id = format!("_x0000_i{}", self.next_shape); + self.next_shape = self.next_shape.saturating_add(1); + let mut kids: Vec = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect(); + kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt))); + vec![Node::El(plain("w:pict", kids))] + } + + /// The `v:shape` holding a picture's `v:imagedata`. + fn shape_frame(&self, id: &str, style: &str, src: &str, alt: &str) -> El { + let mut shape = attr_el( + "v:shape", + &[ + ("id", id), + ("style", style), + ("coordsize", "21600,21600"), + ("o:spt", "75"), + ("path", "m0,0l0,21600,21600,21600,21600,0xe"), + ("filled", "f"), + ("stroked", "f"), + ], + ); + if !alt.is_empty() { + shape.attrs.push(("alt".into(), alt.to_string())); + } + let mut data = attr_el("v:imagedata", &[("src", src)]); + data.attrs.push(("o:title".into(), alt.to_string())); + shape.kids.push(Node::El(data)); + shape + } + + /// A legacy `w:pict` (VML) kept as it is, its pictures given `w:binData`. + fn vml_pict(&mut self, e: &El, rels: &Rels, depth: usize) -> El { + let kids = self.kids(e, rels, "w:pict", depth); + let mut all: Vec = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect(); + all.extend(kids); + plain("w:pict", all) + } + + fn hyperlink(&mut self, e: &El, rels: &Rels, depth: usize) -> El { + let mut a = Vec::new(); + if let Some(r) = e.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| r.external) { + a.push(("w:dest".to_string(), r.target.clone())); + } + for (from, to) in [("w:anchor", "w:bookmark"), ("w:tooltip", "w:screenTip"), ("w:tgtFrame", "w:target")] { + if let Some(v) = e.attr(from) { + a.push((to.to_string(), v.to_string())); + } + } + El { name: "w:hlink".into(), attrs: a, kids: self.kids(e, rels, "w:hlink", depth) } + } + + /// A footnote or endnote reference as the note itself, inline in its run. + fn note(&mut self, e: &El, depth: usize) -> Vec { + let foot = e.name == "w:footnoteReference"; + let Some(id) = e.attr("w:id") else { return Vec::new() }; + if self.in_note || self.stories >= MAX_STORIES { + return Vec::new(); + } + let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() }; + self.stories += 1; + let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() }; + self.in_note = true; + let name = if foot { "w:footnote" } else { "w:endnote" }; + let kids = self.kids(¬e, &rels, name, depth); + self.in_note = false; + vec![Node::El(plain(name, kids))] + } + + fn annotation(&self, kind: &str, id: i64, mut extra: Vec<(String, String)>, content: Option>) -> El { + let mut a = vec![("aml:id".to_string(), id.to_string())]; + a.append(&mut extra); + a.push(("w:type".into(), kind.into())); + El { name: "aml:annotation".into(), attrs: a, kids: content.map(|c| vec![Node::El(plain("aml:content", c))]).unwrap_or_default() } + } + + fn comment(&mut self, e: &El, depth: usize) -> Vec { + let Some(id) = e.attr("w:id") else { return Vec::new() }; + if self.in_note || self.stories >= MAX_STORIES { + return Vec::new(); + } + let Some(c) = self.comments.get(id).cloned() else { return Vec::new() }; + self.stories += 1; + let rels = self.comment_rels.clone(); + let n = self.ann_id('c', id); + self.in_note = true; + let content = self.kids(&c, &rels, "w:comment", depth); + self.in_note = false; + let mut extra = Vec::new(); + if let Some(v) = c.attr("w:author") { + extra.push(("aml:author".to_string(), v.to_string())); + } + if let Some(v) = c.attr("w:date") { + extra.push(("aml:createdate".to_string(), v.to_string())); + } + if let Some(v) = c.attr("w:initials") { + extra.push(("w:initials".to_string(), v.to_string())); + } + vec![Node::El(self.annotation("Word.Comment", n, extra, Some(content)))] + } + + fn range_marker(&mut self, e: &El) -> Vec { + let Some(id) = e.attr("w:id") else { return Vec::new() }; + let (kind, ty) = match e.name.as_str() { + "w:commentRangeStart" => ('c', "Word.Comment.Start"), + "w:commentRangeEnd" => ('c', "Word.Comment.End"), + "w:bookmarkStart" => ('b', "Word.Bookmark.Start"), + _ => ('b', "Word.Bookmark.End"), + }; + let n = self.ann_id(kind, id); + let extra = if e.name == "w:bookmarkStart" { vec![("w:name".to_string(), e.attr("w:name").unwrap_or("").to_string())] } else { Vec::new() }; + vec![Node::El(self.annotation(ty, n, extra, None))] + } + + /// Tracked insertions and deletions (moves become a deletion and an insertion). + fn revision(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec { + let ty = if matches!(e.name.as_str(), "w:ins" | "w:moveTo") { "Word.Insertion" } else { "Word.Deletion" }; + let n = self.ann_id('r', e.attr("w:id").unwrap_or("")); + let mut extra = Vec::new(); + if let Some(v) = e.attr("w:author") { + extra.push(("aml:author".to_string(), v.to_string())); + } + if let Some(v) = e.attr("w:date") { + extra.push(("aml:createdate".to_string(), v.to_string())); + } + let content = self.kids(e, rels, parent, depth); + vec![Node::El(self.annotation(ty, n, extra, Some(content)))] + } + + /// `w:sectPr` with its headers and footers inline (`w:hdr`, `w:ftr`). + fn section(&mut self, e: &El, rels: &Rels, depth: usize) -> El { + let mut stories = Vec::new(); + let mut rest = Vec::new(); + for k in e.els() { + if k.name == "w:headerReference" || k.name == "w:footerReference" { + let footer = k.name == "w:footerReference"; + if self.stories >= MAX_STORIES { + continue; + } + let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue }; + let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue }; + self.stories += 1; + let story_rels = self.pkg.rels(&path); + let ty = match k.attr("w:type") { + Some("even") => "even", + Some("first") => "first", + _ => "odd", + }; + let name = if footer { "w:ftr" } else { "w:hdr" }; + let was = std::mem::replace(&mut self.in_note, true); + let kids = self.kids(&root, &story_rels, name, depth + 1); + self.in_note = was; + let mut story = plain(name, kids); + story.attrs.push(("w:type".into(), ty.into())); + stories.push(Node::El(story)); + } else { + rest.extend(self.node(k, rels, "w:sectPr", depth + 1)); + } + } + stories.extend(rest); + El { name: "w:sectPr".into(), attrs: Vec::new(), kids: stories } + } + + /// `w:fonts/w:defaultFonts` from the document defaults. + fn default_fonts(&self) -> Option { + let f = self.styles_part.as_ref()?.child("w:docDefaults")?.child("w:rPrDefault")?.child("w:rPr")?.child("w:rFonts")?; + let a = attrs(f); + (!a.is_empty()).then(|| plain("w:fonts", vec![Node::El(El { name: "w:defaultFonts".into(), attrs: a, kids: Vec::new() })])) + } + + /// `w:styles`: the styles, with the document defaults (other than fonts) folded into the + /// default paragraph style, as 2003 has no `docDefaults`. + fn styles(&mut self) -> El { + let Some(root) = self.styles_part.clone() else { return plain("w:styles", Vec::new()) }; + let defaults = root.child("w:docDefaults"); + let default_of = |part: &str, block: &str| -> Vec { + defaults + .and_then(|d| d.child(part)) + .and_then(|p| p.child(block)) + .map(|b| b.els().filter(|e| e.name != "w:rFonts").cloned().collect()) + .unwrap_or_default() + }; + let (def_r, def_p) = (default_of("w:rPrDefault", "w:rPr"), default_of("w:pPrDefault", "w:pPr")); + let rels = self.doc_rels.clone(); + let mut kids = Vec::new(); + for st in root.children("w:style") { + let mut st = st.clone(); + if st.attr("w:type") == Some("paragraph") && matches!(st.attr("w:default"), Some("1" | "true" | "on")) { + fold(&mut st, "w:pPr", &def_p); + fold(&mut st, "w:rPr", &def_r); + } + kids.extend(self.node(&st, &rels, "w:styles", 2)); + } + plain("w:styles", kids) + } + + /// `w:lists`: `w:abstractNum` → `w:listDef`, `w:num` → `w:list`. + fn lists(&mut self) -> Option { + let root = self.numbering_part.clone()?; + let rels = self.doc_rels.clone(); + let mut kids = Vec::new(); + for abs in root.children("w:abstractNum") { + let Some(id) = abs.attr("w:abstractNumId") else { continue }; + let mut def = El { name: "w:listDef".into(), attrs: vec![("w:listDefId".into(), id.to_string())], kids: Vec::new() }; + for k in abs.els() { + match k.name.as_str() { + "w:nsid" => def.kids.push(Node::El(attr_el("w:lsid", &[("w:val", k.attr("w:val").unwrap_or(""))]))), + "w:multiLevelType" => { + let v = match k.attr("w:val").unwrap_or("") { + "singleLevel" => "SingleLevel", + "hybridMultilevel" => "HybridMultilevel", + _ => "Multilevel", + }; + def.kids.push(Node::El(attr_el("w:plt", &[("w:val", v)]))); + } + "w:numStyleLink" => def.kids.push(Node::El(attr_el("w:listStyleLink", &[("w:val", k.attr("w:val").unwrap_or(""))]))), + "w:lvl" => def.kids.extend(self.node(k, &rels, "w:abstractNum", 3)), + _ => {} + } + } + kids.push(Node::El(def)); + } + for num in root.children("w:num") { + let (Some(id), Some(abs)) = (num.attr("w:numId"), num.child_val("w:abstractNumId")) else { continue }; + let mut list = El { name: "w:list".into(), attrs: vec![("w:ilfo".into(), id.to_string())], kids: Vec::new() }; + list.kids.push(Node::El(attr_el("w:ilst", &[("w:val", abs)]))); + for o in num.children("w:lvlOverride") { + list.kids.extend(self.node(o, &rels, "w:num", 3)); + } + kids.push(Node::El(list)); + } + (!kids.is_empty()).then(|| plain("w:lists", kids)) + } +} + +/// Add the properties of `defaults` that block `name` of `style` doesn't set (at its start). +fn fold(style: &mut El, name: &str, defaults: &[El]) { + if defaults.is_empty() { + return; + } + let pos = style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name == name)); + let idx = match pos { + Some(i) => i, + None => { + // Schema order within a style: name, basedOn, next, link, … then pPr, rPr. + let at = if name == "w:pPr" { + style + .kids + .iter() + .position(|n| matches!(n, Node::El(e) if e.name == "w:rPr" || e.name.starts_with("w:tbl") || e.name.starts_with("w:t"))) + } else { + style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name.starts_with("w:tbl") || e.name.starts_with("w:t"))) + } + .unwrap_or(style.kids.len()); + style.kids.insert(at, Node::El(plain(name, Vec::new()))); + at + } + }; + if let Some(Node::El(block)) = style.kids.get_mut(idx) { + let have: Vec = block.els().map(|e| e.name.clone()).collect(); + let mut add: Vec = defaults.iter().filter(|d| !have.contains(&d.name)).cloned().map(Node::El).collect(); + add.append(&mut block.kids); + block.kids = add; + } +} + +/// `w:numPr` (`w:ilvl`, `w:numId`) → `w:listPr` (`w:ilvl`, `w:ilfo`). +fn list_pr(e: &El) -> El { + let mut kids = Vec::new(); + if let Some(v) = e.child_val("w:ilvl") { + kids.push(Node::El(attr_el("w:ilvl", &[("w:val", v)]))); + } + if let Some(v) = e.child_val("w:numId") { + kids.push(Node::El(attr_el("w:ilfo", &[("w:val", v)]))); + } + plain("w:listPr", kids) +} + +/// `w:numFmt` → `w:nfc` (a number format code). +fn nfc(e: &El) -> El { + let code = match e.attr("w:val").unwrap_or("decimal") { + "upperRoman" => "1", + "lowerRoman" => "2", + "upperLetter" => "3", + "lowerLetter" => "4", + "ordinal" => "5", + "cardinalText" => "6", + "ordinalText" => "7", + "chicago" => "9", + "decimalFullWidth" => "14", + "decimalHalfWidth" => "15", + "decimalEnclosedCircle" => "18", + "decimalZero" => "22", + "bullet" => "23", + "ganada" => "24", + "chosung" => "25", + "decimalEnclosedFullstop" => "26", + "decimalEnclosedParen" => "27", + "none" => "255", + _ => "0", + }; + attr_el("w:nfc", &[("w:val", code)]) +} + +/// A number without a pointless fraction (`36`, `10.5`). +fn trim_num(v: f64) -> String { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +/// Is this element written? WordprocessingML elements of the 2003 vocabulary, and the VML a +/// picture needs; everything else (DrawingML, `w14`, math, markup compatibility…) is not. +fn allowed(name: &str) -> bool { + match name.split_once(':') { + Some(("w", local)) => allowed_w(local), + Some(("v" | "o" | "w10", _)) => true, + _ => false, + } +} + +fn allowed_w(local: &str) -> bool { + matches!( + local, + "p" | "pPr" + | "r" + | "rPr" + | "t" + | "tab" + | "br" + | "cr" + | "noBreakHyphen" + | "softHyphen" + | "sym" + | "pgNum" + | "delText" + | "instrText" + | "fldChar" + | "fldSimple" + | "footnoteRef" + | "endnoteRef" + | "annotationRef" + | "separator" + | "continuationSeparator" + | "tbl" + | "tblPr" + | "tblGrid" + | "gridCol" + | "tr" + | "trPr" + | "tc" + | "tcPr" + | "sectPr" + | "hdr" + | "ftr" + | "pStyle" + | "keepNext" + | "keepLines" + | "pageBreakBefore" + | "framePr" + | "widowControl" + | "suppressLineNumbers" + | "pBdr" + | "shd" + | "tabs" + | "suppressAutoHyphens" + | "kinsoku" + | "wordWrap" + | "overflowPunct" + | "topLinePunct" + | "autoSpaceDE" + | "autoSpaceDN" + | "bidi" + | "adjustRightInd" + | "snapToGrid" + | "spacing" + | "ind" + | "contextualSpacing" + | "mirrorIndents" + | "textAlignment" + | "textDirection" + | "textboxTightWrap" + | "outlineLvl" + | "jc" + | "divId" + | "top" + | "left" + | "bottom" + | "right" + | "between" + | "bar" + | "insideH" + | "insideV" + | "tl2br" + | "tr2bl" + | "rStyle" + | "rFonts" + | "b" + | "bCs" + | "i" + | "iCs" + | "caps" + | "smallCaps" + | "strike" + | "dstrike" + | "outline" + | "shadow" + | "emboss" + | "imprint" + | "noProof" + | "vanish" + | "webHidden" + | "color" + | "w" + | "kern" + | "position" + | "sz" + | "szCs" + | "highlight" + | "u" + | "effect" + | "bdr" + | "fitText" + | "vertAlign" + | "rtl" + | "cs" + | "em" + | "lang" + | "eastAsianLayout" + | "specVanish" + | "tblStyle" + | "tblpPr" + | "tblOverlap" + | "bidiVisual" + | "tblStyleRowBandSize" + | "tblStyleColBandSize" + | "tblW" + | "tblCellSpacing" + | "tblInd" + | "tblBorders" + | "tblLayout" + | "tblCellMar" + | "cantSplit" + | "trHeight" + | "tblHeader" + | "gridBefore" + | "gridAfter" + | "wBefore" + | "wAfter" + | "hidden" + | "tcW" + | "gridSpan" + | "hMerge" + | "vMerge" + | "tcBorders" + | "noWrap" + | "tcMar" + | "tcFitText" + | "vAlign" + | "pgSz" + | "pgMar" + | "paperSrc" + | "pgBorders" + | "lnNumType" + | "pgNumType" + | "cols" + | "col" + | "formProt" + | "noEndnote" + | "titlePg" + | "rtlGutter" + | "docGrid" + | "type" + | "style" + | "name" + | "aliases" + | "basedOn" + | "next" + | "link" + | "autoRedefine" + | "semiHidden" + | "qFormat" + | "locked" + | "tblStylePr" + | "lvl" + | "start" + | "lvlRestart" + | "isLgl" + | "suff" + | "lvlText" + | "legacy" + | "lvlJc" + | "lvlOverride" + | "startOverride" + | "ilvl" + | "binData" + ) +} + +/// The 2003 spelling of an ECMA-376 name: `w:szCs` → `w:sz-cs`, `w:eastAsia` → `w:fareast`, +/// `w:vMerge` → `w:vmerge`. Names outside `w:` are kept. +fn rename(name: &str) -> String { + let Some(local) = name.strip_prefix("w:") else { return name.to_string() }; + let new = match local { + "eastAsia" => "fareast", + "vMerge" => "vmerge", + "hMerge" => "hmerge", + "bCs" => "b-cs", + "iCs" => "i-cs", + "szCs" => "sz-cs", + "hAnsi" => "h-ansi", + "firstLine" => "first-line", + "lineRule" => "line-rule", + "hRule" => "h-rule", + "linePitch" => "line-pitch", + "firstLineChars" => "first-line-chars", + "hangingChars" => "hanging-chars", + "leftChars" => "left-chars", + "rightChars" => "right-chars", + other => other, + }; + format!("w:{new}") +} + +/// Elements whose `w:val` is an on/off switch (2003 writes `on` / `off`). +fn is_toggle(local: &str) -> bool { + matches!( + local, + "b" | "bCs" + | "i" + | "iCs" + | "caps" + | "smallCaps" + | "strike" + | "dstrike" + | "outline" + | "shadow" + | "emboss" + | "imprint" + | "noProof" + | "snapToGrid" + | "vanish" + | "webHidden" + | "keepNext" + | "keepLines" + | "pageBreakBefore" + | "widowControl" + | "suppressLineNumbers" + | "suppressAutoHyphens" + | "wordWrap" + | "contextualSpacing" + | "mirrorIndents" + | "rtl" + | "cs" + | "specVanish" + | "tblHeader" + | "cantSplit" + | "noWrap" + | "hidden" + | "semiHidden" + | "qFormat" + | "locked" + | "tcFitText" + | "isLgl" + | "bidi" + | "overflowPunct" + | "kinsoku" + | "autoSpaceDE" + | "autoSpaceDN" + | "adjustRightInd" + | "topLinePunct" + | "titlePg" + ) +} + +/// The attributes of `e` that 2003 has, renamed: theme references, revision ids and attributes of +/// other vocabularies (`w14:`, `r:`…) are dropped. +fn attrs(e: &El) -> Vec<(String, String)> { + let local_el = e.name.strip_prefix("w:").unwrap_or(""); + let vml = e.name.starts_with("v:") || e.name.starts_with("o:") || e.name.starts_with("w10:"); + let mut out = Vec::new(); + for (k, v) in &e.attrs { + let key = match k.split_once(':') { + Some(("w", l)) => { + let lower = l.to_ascii_lowercase(); + if lower.starts_with("rsid") || lower.contains("theme") { + continue; + } + rename(k) + } + Some(("xml", "space")) => k.clone(), + Some(("v" | "o" | "w10", _)) if vml => k.clone(), + None if vml => k.clone(), + _ => continue, + }; + let mut val = v.clone(); + if (key == "w:val" && is_toggle(local_el)) || ((key == "w:default" || key == "w:customStyle") && e.name == "w:style") { + val = switch(&val); + } + out.push((key, val)); + } + out +} + +/// An on/off value in 2003's spelling. +fn switch(v: &str) -> String { + match v { + "1" | "true" | "on" => "on".into(), + "0" | "false" | "off" => "off".into(), + other => other.into(), + } +} diff --git a/crates/docx/tests/wordml2003_write.rs b/crates/docx/tests/wordml2003_write.rs new file mode 100644 index 000000000..4ccb3aadf --- /dev/null +++ b/crates/docx/tests/wordml2003_write.rs @@ -0,0 +1,181 @@ +//! Saving as Word 2003 XML (#1081): documents built in code, written as WordprocessingML 2003 +//! and read back with the 2003 reader. + +use std::sync::Arc; + +use wordcraft_doc::numbering::ListKind; +use wordcraft_doc::para::NoteKind; +use wordcraft_doc::props::{Align, CharProps, NumRef, Rgb, TextColor}; +use wordcraft_doc::table::{Cell, Table}; +use wordcraft_doc::{Block, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block}; +use wordcraft_docx::{XmlKind, read_xml, sniff_xml, write_wordml2003}; + +fn tiny_png() -> Vec { + let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +fn paras(doc: &Document) -> Vec<&Paragraph> { + doc.body.iter().filter_map(|b| b.as_para()).collect() +} + +fn part_text(doc: &Document, id: Option) -> String { + id.and_then(|i| doc.parts.get(&i)) + .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::>().join("\n")) + .unwrap_or_default() +} + +fn sample() -> Document { + let mut d = Document::new(); + d.core.title = "Garden & more".into(); + d.core.creator = "Ada".into(); + let key = d.add_media(tiny_png(), "png"); + let num = d.numbering.add_list(ListKind::Numbered); + let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]); + let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]); + let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]); + d.comments.insert(3, Comment { author: "Bo".into(), part: com, ..Default::default() }); + d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None }); + d.last_section.headers.default = Some(head); + + let mut p = Paragraph::new(); + for (t, c) in [ + ("Plain ", CharProps::default()), + ( + "loud", + CharProps { bold: Some(true), italic: Some(true), size: Some(14.0), color: Some(TextColor::Rgb(Rgb(255, 0, 0))), ..Default::default() }, + ), + (" link", CharProps { link: Some("https://example.org/beans?a=1&b=2".into()), ..Default::default() }), + ] { + let off = p.len(); + p.insert_text(off, t, &c).unwrap(); + } + p.props.align = Some(Align::Center); + p.props.indent_left = Some(36.0); + p.props.space_after = Some(12.0); + p.insert_object(0, InlineObject::CommentStart { id: 3 }, &CharProps::default()).unwrap(); + let end = p.len(); + p.insert_object(end, InlineObject::CommentEnd { id: 3 }, &CharProps::default()).unwrap(); + let at = p.text.find("loud").unwrap(); + p.insert_object(at, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap(); + let mut blocks = vec![para_block(p)]; + + let mut li = Paragraph::with_text("listed", CharProps { ins: Some(0), ..Default::default() }); + li.props.numbering = Some(NumRef { num, level: 0 }); + blocks.push(para_block(li)); + + let mut img = Paragraph::new(); + img.insert_object( + 0, + InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None }, + &CharProps::default(), + ) + .unwrap(); + blocks.push(para_block(img)); + + let mut t = Table::new(2, 2, 300.0); + t.rows[0].cells[0] = Cell::with_text("A1"); + t.rows[0].cells[1] = Cell::with_text("B1"); + t.rows[1].cells[0].props.shading = Some(Rgb(0, 128, 0)); + blocks.push(Arc::new(Block::Table(t))); + blocks.push(para_block(Paragraph::new())); + d.body = blocks; + d +} + +#[test] +fn round_trips_through_the_2003_reader() { + let d = sample(); + let xml = write_wordml2003(&d).expect("write"); + let text = std::str::from_utf8(&xml).expect("utf-8"); + assert!(text.contains("") && text.contains("") && text.contains(" & more"); + assert_eq!(back.core.creator, "Ada"); + let ps = paras(&back); + assert_eq!(ps[0].plain_text(), "Plain loud link"); + assert_eq!(ps[0].props.align, Some(Align::Center)); + assert_eq!(ps[0].props.indent_left, Some(36.0)); + assert_eq!(ps[0].props.space_after, Some(12.0)); + let c = ps[0].props_of_char(ps[0].text.find("loud").unwrap()); + assert_eq!((c.bold, c.italic, c.size), (Some(true), Some(true), Some(14.0))); + assert!(c.color.is_some()); + assert_eq!(ps[0].props_at(ps[0].text.find("link").unwrap()).link.as_deref(), Some("https://example.org/beans?a=1&b=2")); + let note = ps[0].objects.iter().find_map(|o| match o { + InlineObject::NoteRef { id, .. } => Some(*id), + _ => None, + }); + assert_eq!(part_text(&back, note), "Sown in May."); + let comment = back.comments.values().next().expect("comment"); + assert_eq!((comment.author.as_str(), part_text(&back, Some(comment.part)).as_str()), ("Bo", "Too short?")); + + let listed = ps[1]; + assert!(listed.props.numbering.is_some(), "list paragraph keeps its list"); + let ins = listed.props_of_char(0).ins.and_then(|i| back.revisions.get(i as usize)); + assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada")); + + let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. })); + assert!(matches!(pic, Some(InlineObject::Image { w, h, alt, .. }) if *w == 30.0 && *h == 20.0 && alt == "dot")); + assert_eq!(back.media.len(), 1); + + let table = back.body.iter().find_map(|b| match b.as_ref() { + Block::Table(t) => Some(t), + _ => None, + }); + let table = table.expect("table"); + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1")); + assert_eq!(table.rows[1].cells[0].props.shading, Some(Rgb(0, 128, 0))); + assert_eq!(part_text(&back, back.last_section.headers.default), "Head"); +} + +/// Features 2003 XML can't carry drop out without an error, and the text stays. +#[test] +fn control_characters_and_unsupported_content_do_not_break_the_file() { + let mut d = Document::new(); + let mut p = Paragraph::with_text("a\u{1}b\u{FFFE}c <&> \"q\"", CharProps { size: Some(f32::NAN), ..Default::default() }); + p.insert_object( + 0, + InlineObject::Opaque { format: "docx".into(), xml: "raw".into(), text: "raw".into() }, + &CharProps::default(), + ) + .unwrap(); + d.body = vec![para_block(p)]; + let xml = write_wordml2003(&d).expect("write"); + assert!(read_xml(&xml).is_ok()); + let text = String::from_utf8(xml).expect("utf-8"); + assert!(!text.contains('\u{1}') && !text.contains('\u{FFFE}')); + assert!(!text.contains(" Vec { .pure(), CommandSpec::new("file.save", "Save", "File", save) .key("Mod+S / Shift+F12") - .params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup)}"#) + .params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#) .pure(), - CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool (as file.save)}"#).pure(), + CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(), // Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit // to undo; it marks the document as changed itself. CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt) @@ -199,11 +204,22 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?; s.password = Some(crate::io::Password::new(pw)); } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + let mut opts = export_options(s, v); + match v.get("format").filter(|f| !f.is_null()) { + None => { + // Saving over a Word 2003 XML file (one opened as such, say) keeps its format. + opts.word2003 = ext == "xml" && is_word_2003_file(&path); + } + Some(f) if matches!(f.as_str(), Some(FORMAT_WORD_2003 | FORMAT_FLAT_OPC)) && ext == "xml" => {} + Some(_) => { + return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file"))); + } + } s.stamp_save(); let password = s.password.as_ref().map(crate::io::Password::as_str); - crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?; + crate::io::save_path_with(&path, &s.doc, password, &opts).map_err(CmdError::Failed)?; let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy()); - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) { s.path = Some(path.clone()); s.dirty = false; @@ -211,6 +227,27 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted})) } +/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)? +#[cfg(not(target_arch = "wasm32"))] +fn is_word_2003_file(path: &std::path::Path) -> bool { + use std::io::Read; + if crate::file_access::check_read(&path.to_string_lossy()).is_err() { + return false; + } + let Ok(f) = std::fs::File::open(path) else { return false }; + let mut head = Vec::new(); + // The root element and its namespaces come first. + if f.take(64 * 1024).read_to_end(&mut head).is_err() { + return false; + } + wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003 +} + +#[cfg(target_arch = "wasm32")] +fn is_word_2003_file(_path: &std::path::Path) -> bool { + false +} + fn save_as(s: &mut Session, v: &Value) -> CmdResult { if p::str(v, "path").is_none() { s.ui_requests.push(json!({"open": "saveAs"})); @@ -223,7 +260,7 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult { /// setting (`file.printMarkup`); comments as Track Changes Options show them. pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions { let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup); - crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments } + crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) } } fn export_png(s: &mut Session, v: &Value) -> CmdResult { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 6e2e93896..fe451ff96 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -51,6 +51,9 @@ pub struct ExportOptions { pub markup: bool, /// With `markup`, show the comments (Track Changes Options › Comments). pub comments: bool, + /// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML + /// Document (Flat OPC): `format: "wordml2003"` on the save commands. + pub word2003: bool, } /// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`). diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 04bd7e484..12eb6dccb 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -26,6 +26,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string())); } match ext { + "xml" if opts.word2003 => Some(wordcraft_docx::write_wordml2003(doc).map_err(|e| e.to_string())), "xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())), "png" => Some(render_png(doc, 0, 2.0, opts)), "pdf" => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 02c5b5d80..bf3a9e838 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3558,6 +3558,33 @@ fn word_xml_saves_and_opens_through_io() { assert!(err.contains("not a Word XML document"), "{err}"); } +/// #1081: `format: "wordml2003"` saves an `.xml` file as a Word 2003 XML Document, a plain save +/// over such a file keeps that format, and `format: "flatopc"` converts it back. +#[test] +fn word_2003_xml_saves_through_the_save_commands() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Kept for old readers"})); + let dir = std::env::temp_dir().join(format!("wordcraft-2003-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("old.xml").to_string_lossy().to_string(); + let kind = |p: &str| wordcraft_docx::sniff_xml(&std::fs::read(p).unwrap()); + run(&mut s, "file.saveAs", json!({"path": path, "format": "wordml2003"})); + assert_eq!(kind(&path), wordcraft_docx::XmlKind::WordMl2003); + assert!(crate::io::open_path(std::path::Path::new(&path)).unwrap().plain_text(wordcraft_doc::StoryRef::Body).contains("Kept for old readers")); + run(&mut s, "file.save", json!({})); + assert_eq!(kind(&path), wordcraft_docx::XmlKind::WordMl2003); + run(&mut s, "file.save", json!({"path": path, "format": "flatopc"})); + assert_eq!(kind(&path), wordcraft_docx::XmlKind::FlatOpc); + for bad in [ + json!({"path": dir.join("a.docx").to_string_lossy(), "format": "wordml2003"}), + json!({"path": path, "format": "bogus"}), + json!({"path": path, "format": 7}), + ] { + assert!(s.run("file.save", &bad).is_err(), "{bad}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// #146: the first/last column, total row and banded column regions apply in layout (later /// regions win: the total row's fill beats the first column's) and round-trip through .docx. #[test] diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index dc597be46..a8ff95ecf 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -215,7 +215,13 @@ impl WordApp { } AfterPick::Export { ext } => { let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; - let r = if ext == "png" { self.run("file.exportPng", json!({"path": path})) } else { self.run("file.saveAs", json!({"path": path})) }; + let r = if ext == "png" { + self.run("file.exportPng", json!({"path": path})) + } else if ext == crate::print_page::EXPORT_WORD_2003 { + self.run("file.saveAs", json!({"path": path, "format": ext})) + } else { + self.run("file.saveAs", json!({"path": path})) + }; if r.is_ok() { self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)])); } @@ -272,7 +278,8 @@ impl WordApp { /// File › Export: ask where to save a copy in this format, then write it. pub(crate) fn export_dialog(&mut self, ext: &str) { - let name = format!("{}.{ext}", self.title_stem()); + let file_ext = if ext == crate::print_page::EXPORT_WORD_2003 { "xml" } else { ext }; + let name = format!("{}.{file_ext}", self.title_stem()); let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() }); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index cccd22fc5..7e0a9c234 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2158,6 +2158,7 @@ Use the Insert key to switch overtype on and off Използване на клавиша Insert за включване и изключване на заместването Extend Selection by Units Разширяване на селекцията по единици Word XML Document (*.xml) XML документ на Word (*.xml) + Word 2003 XML Document (*.xml) XML документ на Word 2003 (*.xml) Deleted Изтрито Print Markup Печат на маркировките Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментари до всяка страница и проследени промени в PDF файлове, разпечатки и изображения; списък с коментари в края при уеб страници, Markdown и текст. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 51b9962ca..553e8d224 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1876,6 +1876,7 @@ Animate the caret (it glides and fades) Animovat kurzor (plynule se přesouvá a prolíná) On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Zapnuto: kurzor se při psaní nebo použití kláves se šipkami plynule přesune na nové místo a jeho blikání se prolíná. Vypnuto: kurzor přeskakuje a blikání se ostře zapíná a vypíná. Word XML Document (*.xml) Dokument Word XML (*.xml) + Word 2003 XML Document (*.xml) Dokument Word 2003 XML (*.xml) Deleted Odstraněno Resize Table by Dragging Změnit velikost tabulky přetažením Select Column Vybrat sloupec diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4d97ea4b8..3f085de06 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1459,6 +1459,7 @@ Animate the caret (it glides and fades) Einfügemarke animieren (sie gleitet und blendet aus) On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Ein: Die Einfügemarke gleitet beim Tippen oder mit den Pfeiltasten an ihre neue Stelle, und ihr Blinken blendet weich. Aus: Sie springt und blinkt hart an und aus. Word XML Document (*.xml) Word-XML-Dokument (*.xml) + Word 2003 XML Document (*.xml) Word-2003-XML-Dokument (*.xml) Choose the sheet that holds the recipients. Wählen Sie das Tabellenblatt mit den Empfängern. First row of data contains column headers Erste Datenzeile enthält Spaltenüberschriften Deleted Gelöscht diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 772227a9b..31ee6dedd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -999,6 +999,7 @@ Word Right Palabra a la derecha Word document (*.docx) Documento de Word (*.docx) Word XML Document (*.xml) Documento XML de Word (*.xml) + Word 2003 XML Document (*.xml) Documento XML de Word 2003 (*.xml) WordArt WordArt WordCraft WordCraft WordCraft is now the default app for Word documents. WordCraft es ahora la aplicación predeterminada para los documentos de Word. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index dce73b871..57893526c 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -877,6 +877,7 @@ Word Right Sõna paremale Word document (*.docx) Wordi dokument (*.docx) Word XML Document (*.xml) Wordi XML-dokument (*.xml) + Word 2003 XML Document (*.xml) Word 2003 XML-dokument (*.xml) WordArt WordArt WordCraft WordCraft Words Sõnad diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2717aff6f..cee1550f9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1908,6 +1908,7 @@ Animate the caret (it glides and fades) Animer le curseur (il glisse et s’estompe) On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Activé : le curseur glisse jusqu’à sa nouvelle position lorsque vous tapez ou utilisez les touches fléchées, et son clignotement s’estompe en douceur. Désactivé : il saute et clignote de façon nette. Word XML Document (*.xml) Document XML Word (*.xml) + Word 2003 XML Document (*.xml) Document XML Word 2003 (*.xml) Deleted Supprimé Resize Table by Dragging Redimensionner le tableau en le faisant glisser Print Markup Imprimer les marques de révision diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 91429c659..8a291fc5f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2317,6 +2317,7 @@ Previous Page Előző oldal Print to PDF Nyomtatás PDF-be Word XML Document (*.xml) Word XML-dokumentum (*.xml) + Word 2003 XML Document (*.xml) Word 2003 XML-dokumentum (*.xml) Use wildcards Helyettesítő karakterek használata Special Különleges Match prefix Egyezés a szó elején diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cb3da2b44..c840e0ca9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1534,6 +1534,7 @@ Use the Insert key to switch overtype on and off Usa il tasto Ins per attivare e disattivare la sovrascrittura Extend Selection by Units Estendi selezione per unità Word XML Document (*.xml) Documento Word XML (*.xml) + Word 2003 XML Document (*.xml) Documento Word 2003 XML (*.xml) Deleted Eliminato Print Markup Stampa revisioni Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Commenti accanto a ogni pagina e revisioni in PDF, stampe e immagini; un elenco dei commenti alla fine di pagine web, Markdown e testo. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ad54cb9cb..b9602c39a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -950,6 +950,7 @@ Word Count 文字カウント Word document (*.docx) Word 文書 (*.docx) Word XML Document (*.xml) Word XML ドキュメント (*.xml) + Word 2003 XML Document (*.xml) Word 2003 XML ドキュメント (*.xml) Word Left 前の単語へ Word Right 次の単語へ WordArt ワードアート diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 589aaa9df..39feff3d9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2052,6 +2052,7 @@ White space Odstępy With: Na: Word XML Document (*.xml) Dokument XML programu Word (*.xml) + Word 2003 XML Document (*.xml) Dokument XML programu Word 2003 (*.xml) Word document Dokument programu Word Word level Poziom wyrazów WordArt Styles Style WordArt diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c553d6b3e..64f18edea 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -950,6 +950,7 @@ Word Count Contagem de palavras Word document (*.docx) Documento Word (*.docx) Word XML Document (*.xml) Documento XML do Word (*.xml) + Word 2003 XML Document (*.xml) Documento XML do Word 2003 (*.xml) Word Left Palavra à esquerda Word Right Palavra à direita WordArt Texto artístico diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b038ff5fa..0c9e77990 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2202,6 +2202,7 @@ preview Title Пример Print to PDF Печать в PDF # Word XML Document (#548). Word XML Document (*.xml) Документ Word XML (*.xml) + Word 2003 XML Document (*.xml) Документ Word 2003 XML (*.xml) # Find and Replace special characters and wildcards (#543). Use wildcards Подстановочные знаки Special Специальный diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 242cfb022..26652040d 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1010,6 +1010,7 @@ Word Count Broj reči Word document (*.docx) Word dokument (*.docx) Word XML Document (*.xml) Word XML dokument (*.xml) + Word 2003 XML Document (*.xml) Word 2003 XML dokument (*.xml) Word Left Reč levo Word Right Reč desno WordArt WordArt diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 085652894..38c925863 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1014,6 +1014,7 @@ Word Count Број речи Word document (*.docx) Word документ (*.docx) Word XML Document (*.xml) Word XML документ (*.xml) + Word 2003 XML Document (*.xml) Word 2003 XML документ (*.xml) Word Left Реч лево Word Right Реч десно WordArt WordArt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 636cbfe02..1f299b8eb 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2201,6 +2201,7 @@ preview Title Örnek Print to PDF PDF'ye yazdır # Word XML Document (#548). Word XML Document (*.xml) Word XML Belgesi (*.xml) + Word 2003 XML Document (*.xml) Word 2003 XML Belgesi (*.xml) # Find and Replace special characters and wildcards (#543). Use wildcards Joker karakter kullan Special Özel diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 03b83976f..cd0204b5e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -978,6 +978,7 @@ Word Count Кількість слів Word document (*.docx) Документ Word (*.docx) Word XML Document (*.xml) Документ Word XML (*.xml) + Word 2003 XML Document (*.xml) Документ Word 2003 XML (*.xml) Word Left На слово ліворуч Word Right На слово праворуч WordArt WordArt diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 94b8846fd..4c495e93d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -962,6 +962,7 @@ Word Count 字数统计 Word document (*.docx) Word 文档 (*.docx) Word XML Document (*.xml) Word XML 文档 (*.xml) + Word 2003 XML Document (*.xml) Word 2003 XML 文档 (*.xml) Word Left 左移一个单词 Word Right 右移一个单词 WordArt 艺术字 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 482e7c7c9..b0dea43af 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -951,6 +951,7 @@ Word Count 字數統計 Word document (*.docx) Word 文件 (*.docx) Word XML Document (*.xml) Word XML 文件 (*.xml) + Word 2003 XML Document (*.xml) Word 2003 XML 文件 (*.xml) Word Left 左移一個單字 Word Right 右移一個單字 WordArt 文字藝術師 diff --git a/crates/ui-egui/src/print_page.rs b/crates/ui-egui/src/print_page.rs index 50e8b93b0..441c6b567 100644 --- a/crates/ui-egui/src/print_page.rs +++ b/crates/ui-egui/src/print_page.rs @@ -233,12 +233,16 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { }); } +/// The export "format" of a Word 2003 XML Document: an `.xml` file saved with `format: "wordml2003"`. +pub(crate) const EXPORT_WORD_2003: &str = "wordml2003"; + /// The "save a copy" formats (File › Export, and below the settings on the Print page). pub fn export_formats(app: &mut WordApp, ui: &mut Ui) { for (label, ext) in [ ("PDF document (*.pdf)", "pdf"), ("Word document (*.docx)", "docx"), ("Word XML Document (*.xml)", "xml"), + ("Word 2003 XML Document (*.xml)", EXPORT_WORD_2003), ("OpenDocument Text (*.odt)", "odt"), ("Rich Text Format (*.rtf)", "rtf"), ("Web page (*.html)", "html"), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6bdc1ca4a..b5c7376c7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (direct run-level InkML content parts now survive DOCX saves; 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-12 · **Change:** trivial (saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; 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; @@ -31,7 +31,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | -| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | ❌ | ~55% | 2–3 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Saving as 2003 XML is not planned (legacy) | +| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | | Encrypted/password-protected `.docx`/`.doc` | 1% | R/W | ❌ | ❌ | 0% | 6–10 | — | ECMA-376 Agile Encryption; `.doc` RC4/XOR detected and refused (`docbin` fib). Issue #55 | | `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources | @@ -177,6 +177,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-12 | trivial | DOCX: `w:hideMark` on table cells read and written | | 2026-10-12 | trivial | DOCX text frames (`w:framePr` other than drop caps) are read and written back; they were dropped on save | | 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% | +| 2026-10-12 | trivial | Word 2003 XML Document (WordprocessingML 2003) written (#1081): Export, `format: "wordml2003"`, CLI `--format`; not yet checked in Word | | 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | | 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly | | 2026-10-11 | trivial | List and page number formats: all ST_NumberFormat values round-trip; Chinese, Japanese and Korean counting, legal and digit formats, stems/branches, kana, hangul, enclosed and full-width numbers drawn; unknown values are decimal; .doc number format codes mapped (#534) | diff --git a/docs/gaps.md b/docs/gaps.md index dcc4c4325..f091bbfe7 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-12 · **Change:** trivial (direct run-level native InkML content parts now survive DOCX saves; 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-12 · **Change:** trivial (saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; 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. @@ -40,7 +40,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | -| 26 | **Minor formats**: MHT, Works/WordPerfect, saving Word 2003 XML | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) | Rare | 15–28 | [file-format-parity.md](file-format-parity.md) | +| 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -88,6 +88,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers | | 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains | | 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet | +| 2026-10-12 | trivial | Saving Word 2003 XML Document landed (#1081); gap #26 narrowed to 14–26 h | | 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts | From 2689f3c6a3d87a9737fdcb42d090a25bb49c72b7 Mon Sep 17 00:00:00 2001 From: RegalDragoon200 <56407824+regalf@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:52:47 +0200 Subject: [PATCH 006/199] Italian proofing: it_IT and it_CH spelling, suggestions, grammar and hyphenation (#951) (#1090) * Italian proofing: it-IT spelling, suggestions and grammar (#951) * Italian proofing: Review Language menu and editing language * Italian proofing: interface test for editing language * Italian proofing: find the Swiss it_CH dictionary too * Italian proofing: TeX hyphenation patterns (MIT option) --------- Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + NOTICE | 21 ++ assets/hyphenation/it.pat | 431 ++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 42 +++ crates/proof/src/hyphen.rs | 66 +++++ crates/proof/src/it/grammar.rs | 256 ++++++++++++++++++ crates/proof/src/it/mod.rs | 189 ++++++++++++++ crates/proof/src/it/suggest.rs | 435 +++++++++++++++++++++++++++++++ crates/proof/src/it/test_dict.rs | 138 ++++++++++ crates/proof/src/lang.rs | 13 +- crates/proof/src/lib.rs | 37 ++- crates/ui-egui/src/i18n/mod.rs | 12 +- crates/ui-egui/src/i18n/tests.rs | 22 +- crates/ui-egui/src/ribbon.rs | 13 +- docs/gaps.md | 3 +- docs/localization-parity.md | 8 +- docs/target-app-parity.md | 3 +- 17 files changed, 1675 insertions(+), 15 deletions(-) create mode 100644 assets/hyphenation/it.pat create mode 100644 crates/proof/src/it/grammar.rs create mode 100644 crates/proof/src/it/mod.rs create mode 100644 crates/proof/src/it/suggest.rs create mode 100644 crates/proof/src/it/test_dict.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1bf3fa163..7942228d6 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -13,6 +13,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) | | `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation | | `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation | +| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation | | `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands | | `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | diff --git a/NOTICE b/NOTICE index 8d09e43bd..751ff5a52 100644 --- a/NOTICE +++ b/NOTICE @@ -52,6 +52,27 @@ list (author, source, license) is in ATTRIBUTION.md. ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8 + package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT + License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file: + + Copyright (C) 2008-2011 Claudio Beccari + + Permission is hereby granted, free of charge, to any person obtaining a copy of this + software and associated documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons + to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or + substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE + FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …) diff --git a/assets/hyphenation/it.pat b/assets/hyphenation/it.pat new file mode 100644 index 000000000..552bb5d26 --- /dev/null +++ b/assets/hyphenation/it.pat @@ -0,0 +1,431 @@ +% title: Hyphenation patterns for Italian +% copyright: Copyright (C) 2008-2011 Claudio Beccari +% notice: This file is part of the hyph-utf8 package. +% See http://www.hyphenation.org/tex for more information. +% language: +% name: Italian +% tag: it +% version: 4.9 2014/04/22 +% authors: +% - +% name: Claudio Beccari +% contact: claudio.beccari (at) gmail.com +% licence: +% - This file is available under any of the following licences: +% - +% name: LPPL +% version: 1.3 +% or_later: true +% url: http://www.latex-project.org/lppl.txt +% status: maintained +% maintainer: Claudio Beccari, e-mail claudio dot beccari at gmail dot com +% - +% name: MIT +% url: https://opensource.org/licenses/MIT +% text: > +% Permission is hereby granted, free of charge, to any person +% obtaining a copy of this software and associated documentation +% files (the "Software"), to deal in the Software without +% restriction, including without limitation the rights to use, +% copy, modify, merge, publish, distribute, sublicense, and/or sell +% copies of the Software, and to permit persons to whom the +% Software is furnished to do so, subject to the following +% conditions: +% +% The above copyright notice and this permission notice shall be +% included in all copies or substantial portions of the Software. +% +% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +% OTHER DEALINGS IN THE SOFTWARE. +% hyphenmins: +% typesetting: +% left: 2 +% right: 2 +% changes: +% - 2014-04-22 - Add few patterns involving `h' +% - 2011-08-16 - Change the licence from GNU LGPL into LPPL v1.3. +% - 2010-05-24 - Fix for Italian patterns for proper hyphenation of -ich and Ljubljana. +% - 2008-06-09 - Import of original ithyph.tex into hyph-utf8 package. +% - 2008-03-08 - (last change in ithyph.tex) +% texlive: +% encoding: ascii +% babelname: italian +% legacy_patterns: ithyph.tex +% message: Italian hyphenation patterns +% description: |- +% Hyphenation patterns for Italian in ASCII encoding. +% Compliant with the Recommendation UNI 6461 on hyphenation +% issued by the Italian Standards Institution +% (Ente Nazionale di Unificazione UNI). +% ========================================== +% +% These hyphenation patterns for the Italian language are supposed to comply +% with the Recommendation UNI 6461 on hyphenation issued by the Italian +% Standards Institution (Ente Nazionale di Unificazione UNI). No guarantee +% or declaration of fitness to any particular purpose is given and any +% liability is disclaimed. +% +\patterns{ +.a3p2n % After the Garzanti dictionary: a-pnea, a-pnoi-co,... +.anti1 +.anti3m2n +.bio1 +.ca4p3s +.circu2m1 +.contro1 +.di2s3cine +.e2x1eu +.fran2k3 +.free3 +.li3p2sa +.narco1 +.opto1 +.orto3p2 +.para1 +.ph2l +.ph2r +.poli3p2 +.pre1 +.p2s +.re1i2scr +.sha2re3 +.tran2s3c +.tran2s3d +.tran2s3l +.tran2s3n +.tran2s3p +.tran2s3r +.tran2s3t +.su2b3lu +.su2b3r +.wa2g3n +.wel2t1 +2'2 +a1ia +a1ie +a1io +a1iu +a1uo +a1ya +2at. +e1iu +e2w +o1ia +o1ie +o1io +o1iu +1b +2bb +2bc +2bd +2bf +2bm +2bn +2bp +2bs +2bt +2bv +b2l +b2r +2b. +2b' +1c +2cb +2cc +2cd +2cf +2ck +2cm +2cn +2cq +2cs +2ct +2cz +2chh +c2h +2ch. +2ch'. +2ch''. +2chb +ch2r +2chn +c2l +c2r +2c. +2c' +.c2 +1d +2db +2dd +2dg +2dl +2dm +2dn +2dp +d2r +2ds +2dt +2dv +2dw +2d. +2d' +.d2 +1f +2fb +2fg +2ff +2fn +f2l +f2r +2fs +2ft +2f. +2f' +1g +2gb +2gd +2gf +2gg +g2h +g2l +2gm +g2n +2gp +g2r +2gs +2gt +2gv +2gw +2gz +2gh2t +2g. +2g' +.h2 +1h +2hb +2hd +2hh +hi3p2n +h2l +2hm +2hn +2hr +2hv +2h. +2h' +.j2 +1j +2j. +2j' +.k2 +1k +2kg +2kf +k2h +2kk +k2l +2km +k2r +2ks +2kt +2k. +2k' +1l +2lb +2lc +2ld +2l3f2 +2lg +l2h +l2j +2lk +2ll +2lm +2ln +2lp +2lq +2lr +2ls +2lt +2lv +2lw +2lz +2l. +2l'. +2l'' +1m +2mb +2mc +2mf +2ml +2mm +2mn +2mp +2mq +2mr +2ms +2mt +2mv +2mw +2m. +2m' +1n +2nb +2nc +2nd +2nf +2ng +2nk +2nl +2nm +2nn +2np +2nq +2nr +2ns +n2s3fer +2nt +2nv +2nz +n2g3n +2nheit +2n. +2n' +1p +2pd +p2h +p2l +2pn +3p2ne +2pp +p2r +2ps +3p2sic +2pt +2pz +2p. +2p' +1q +2qq +2q. +2q' +1r +2rb +2rc +2rd +2rf +r2h +2rg +2rk +2rl +2rm +2rn +2rp +2rq +2rr +2rs +2rt +r2t2s3 +2rv +2rx +2rw +2rz +2r. +2r' +1s2 +2shm +2sh. +2sh' +2s3s +s4s3m +2s3p2n +2stb +2stc +2std +2stf +2stg +2stm +2stn +2stp +2sts +2stt +2stv +2sz +4s. +4s'. +4s'' +.t2 +1t +2tb +2tc +2td +2tf +2tg +t2h +2th. +t2l +2tm +2tn +2tp +t2r +t2s +3t2sch +2tt +t2t3s +2tv +2tw +t2z +2tzk +tz2s +2t. +2t'. +2t'' +1v +2vc +v2l +v2r +2vv +2v. +2v'. +2v'' +1w +w2h +wa2r +2w1y +2w. +2w' +1x +2xb +2xc +2xf +2xh +2xm +2xp +2xt +2xw +2x. +2x' +y1ou +y1i +1z +2zb +2zd +2zl +2zn +2zp +2zt +2zs +2zv +2zz +2z. +2z'. +2z'' +.z2 +} % Pattern end + diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bf3a9e838..7cc61c8e6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4327,6 +4327,48 @@ fn portuguese_proofing_through_the_review_commands() { /// Review › Language and the proofing commands work in Polish end to end: the language makes /// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar, /// suggestions come from it, and a suggestion fixes the text. +fn italian_test_dictionary() { + let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; + let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; + wordcraft_proof::it::install_dictionary(aff, dic).unwrap(); +} + +/// Review › Language and the proofing commands work in Italian end to end: the language makes +/// the document's words Italian, F7 and the issue list use the Italian dictionary and grammar, +/// suggestions come from it, and a suggestion fixes the text. +#[test] +fn italian_proofing_through_the_review_commands() { + italian_test_dictionary(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Casa caffe, e roma roma disse."})); + // English by default: the Italian words are English misspellings. + assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 3); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "it_IT", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("it-IT"), "the tag is normalized"); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "it-IT"); + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + let has = |text: &str, kind: &str, sugg: &str| { + arr.iter() + .any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "it" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) + }; + assert!(has("caffe", "spelling", "caffè"), "{issues}"); + assert!(has("roma roma", "grammar", "roma"), "{issues}"); + assert_eq!(arr.len(), 2, "{issues}"); + assert!(arr.iter().any(|i| i["message"] == "Parola ripetuta: \"roma\""), "{issues}"); + // F7 from the start selects the first issue; Change applies a suggestion. + run(&mut s, "caret.docStart", json!({})); + let first = run(&mut s, "review.spelling", json!({})); + assert_eq!(first["text"], "caffe"); + run(&mut s, "review.applySuggestion", json!({"text": "caffè"})); + assert!(text(&s).starts_with("Casa caffè,"), "{}", text(&s)); + // Add to Dictionary works for Italian words too. + run(&mut s, "review.addToDictionary", json!({"word": "caffe"})); + let left = run(&mut s, "review.issues", json!({})); + assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); +} + #[test] fn polish_proofing_through_the_review_commands() { polish_test_dictionary(); diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 89ab24004..c8a17d32f 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -33,6 +33,9 @@ static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat"); static PL_PAT: &str = include_str!("../../../assets/hyphenation/pl.pat"); /// hyph-utf8's `hyph-pt.tex`, unmodified (its BSD 3-clause header is in the file). static PT_PAT: &str = include_str!("../../../assets/hyphenation/pt.pat"); +/// hyph-utf8's `hyph-it.tex`, unmodified (offered under MIT or LPPL; WordCraft uses MIT, whose +/// notice is in the file's header). +static IT_PAT: &str = include_str!("../../../assets/hyphenation/it.pat"); impl Dictionary { /// The bundled US English dictionary (decoded on first use). @@ -68,6 +71,12 @@ impl Patterns { static P: OnceLock = OnceLock::new(); P.get_or_init(|| Patterns::parse(PT_PAT)) } + + /// The bundled Italian patterns (parsed on first use; the file has no exception words). + pub fn it() -> &'static Patterns { + static P: OnceLock = OnceLock::new(); + P.get_or_init(|| Patterns::parse(IT_PAT)) + } } /// Hyphenation limits from the paragraph's Hyphenation settings. @@ -121,6 +130,9 @@ fn core_breaks(core: &[char], lang: ProofLang) -> (Vec, Source) { if lang == ProofLang::Pt { return core_breaks_pt(core); } + if lang == ProofLang::It { + return core_breaks_it(core); + } let dict = Dictionary::en_us(); let s: String = core.iter().collect(); let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::>(); @@ -175,6 +187,22 @@ fn core_breaks_pt(core: &[char]) -> (Vec, Source) { (heuristic_points(core), Source::Heuristic) } +/// [`core_breaks_pt`] for Italian: the bundled patterns (the file has no exception words), +/// then the generic heuristic. +fn core_breaks_it(core: &[char]) -> (Vec, Source) { + let pats = Patterns::it(); + let s: String = core.iter().collect(); + if let Some(p) = pats.exception(&s) { + return (p.to_vec(), Source::Dictionary); + } + if !pats.is_empty() + && let Some(p) = pats.points(core) + { + return (p, Source::Patterns); + } + (heuristic_points(core), Source::Heuristic) +} + /// Unrestricted break points of an English `word` ([`word_breaks_in`] with [`ProofLang::En`]). pub fn word_breaks(word: &str) -> (Vec, Source) { word_breaks_in(word, ProofLang::En) @@ -706,6 +734,44 @@ mod tests { hyphenate_word_in(w, &Limits::default(), ProofLang::Pt) } + fn it(w: &str) -> String { + hyphenate_word_in(w, &Limits::default(), ProofLang::It) + } + + #[test] + fn italian_patterns_load() { + let p = Patterns::it(); + assert!(p.len() > 300, "{}", p.len()); + // The file has no exception words. + assert_eq!(p.exception_count(), 0); + } + + #[test] + fn italian_words_hyphenate_by_italian_rules() { + for (w, want) in [ + ("famiglia", "fa-mi-glia"), + ("finestra", "fi-ne-stra"), + ("bicicletta", "bi-ci-clet-ta"), + ("passeggiata", "pas-seg-gia-ta"), + ("caffè", "caf-fè"), + ("tavolo", "ta-vo-lo"), + ("bambino", "bam-bi-no"), + ("scuola", "scuo-la"), + ("notte", "not-te"), + ("giorno", "gior-no"), + ("acqua", "ac-qua"), + // Under five letters (`Limits::min_word`): no break. + ("casa", "casa"), + ("pane", "pane"), + ("libro", "li-bro"), + ("re", "re"), + ("più", "più"), + ] { + assert_eq!(it(w), want, "{w}"); + } + assert_eq!(word_breaks_in("famiglia", ProofLang::It).1, Source::Patterns); + } + #[test] fn portuguese_patterns_load() { let p = Patterns::pt(); diff --git a/crates/proof/src/it/grammar.rs b/crates/proof/src/it/grammar.rs new file mode 100644 index 000000000..112b0c2a0 --- /dev/null +++ b/crates/proof/src/it/grammar.rs @@ -0,0 +1,256 @@ +//! Italian grammar and punctuation checks: cheap, rule-based and tuned to stay quiet when +//! unsure (a missed mistake costs less than a wrong mark on correct text). Messages are in +//! Italian. +//! +//! - **Repeated word** (`la la`), except words Italian doubles on purpose (`piano piano`, +//! `sì sì`, interjections). +//! - **Space before punctuation** (`bene ,`) and **double spaces**; emoticons (` :)`) and +//! ellipses are left alone. +//! - **Capital letter at the start of a sentence**, knowing Italian abbreviations (`ecc.`, +//! `dott.`, `prof.`, `sig.`), initials, ordinal numbers (`1. posto`) and ellipses. +//! - **Doubled punctuation** (`,,`, `..`, `;;`). +//! +//! No comma rule and no English rule (such as `a`/`an`) applies to Italian text. + +use crate::{Issue, IssueKind, ProofLang, words}; + +/// Words Italian repeats on purpose: emphatic doubling, interjections, onomatopoeia. +const ALLOWED_REPEATS: &[&str] = &[ + "sì", "no", "piano", "bene", "male", "ecco", "oh", "ah", "eh", "mh", "bah", "ehi", "urrà", "evviva", "ciao", "addio", "adagio", "forte", "lento", + "chiaro", +]; + +/// Abbreviations written with a final period after which a sentence goes on in lower case +/// (lower case, without the period). +const ABBREVIATIONS: &[&str] = &[ + "ecc", + "es", + "dott", + "prof", + "profssa", + "ing", + "avv", + "sig", + "sigg", + "sigra", + "sigr", + "geom", + "rag", + "dottssa", + "avvoc", + "pag", + "pagg", + "tel", + "art", + "artt", + "fig", + "figg", + "vol", + "voll", + "ed", + "sez", + "cap", + "capp", + "par", + "n", + "num", + "vs", + "cfr", + "cfrt", + "op", + "cit", + "ibid", + "sez", + "all", + "tav", + "pag", + "ditta", + "soc", + "spa", + "srl", + "on", + "sen", + "spett", + "eg", + "ca", + "v", + "vv", + "sec", + "min", + "max", + "lun", + "mar", + "mer", + "gio", + "ven", + "sab", + "dom", + "gen", + "feb", + "mar", + "apr", + "mag", + "giu", + "lug", + "ago", + "set", + "ott", + "nov", + "dic", + "genn", + "febb", + "sett", + "dottor", + "professor", + "cav", + "commend", + "mons", + "fr", + "suor", + "s", + "ss", + "b", + "beato", +]; + +fn issue(start: usize, end: usize, message: String, suggestions: Vec) -> Issue { + Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang: ProofLang::It } +} + +/// Is `prev`, before a period, an abbreviation, an initial, a number or a Roman numeral (after +/// which a lower-case word doesn't start a sentence)? +fn abbreviation_or_number(prev: &str) -> bool { + prev.chars().count() <= 1 + || prev.chars().any(|c| c.is_ascii_digit()) + || (prev.chars().all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M'))) + || ABBREVIATIONS.contains(&prev.to_lowercase().as_str()) +} + +/// Does the gap between two words end a sentence: a `.`, `!` or `?` (not an ellipsis, not after +/// an abbreviation), then space, with nothing but quotes and brackets around? +fn ends_sentence(gap: &str, prev: &str) -> bool { + let t = gap.trim_start_matches(['"', '”', '’', '\'', ')', '»', ']', '\u{1}']); + let Some(end) = t.chars().next() else { return false }; + if !matches!(end, '.' | '!' | '?') || t.starts_with("..") { + return false; + } + if end == '.' && abbreviation_or_number(prev) { + return false; + } + let rest = t.trim_start_matches(['.', '!', '?']).trim_start_matches(['"', '”', '’', '\'', ')', '»', ']']); + rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| matches!(c, '"' | '“' | '«' | '(' | '[' | '\'' | '—')) +} + +fn repeated_words(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for pair in ws.windows(2) { + let [(a0, b0), (a1, b1)] = pair else { continue }; + let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue }; + if !gap.is_empty() + && gap.chars().all(char::is_whitespace) + && w0.chars().all(char::is_alphabetic) + && w0.to_lowercase() == w1.to_lowercase() + && !ALLOWED_REPEATS.contains(&w0.to_lowercase().as_str()) + { + v.push(issue(*a0, *b1, format!("Parola ripetuta: \"{w1}\""), vec![w0.to_string()])); + } + } +} + +fn spaces(text: &str, v: &mut Vec) { + for (i, c) in text.char_indices() { + if c != ' ' { + continue; + } + let rest = text.get(i + 1..).unwrap_or(""); + let mut it = rest.chars(); + let Some(p) = it.next() else { continue }; + let after = it.next(); + let flag = match p { + ',' => true, + // `;)` and `;-)` are emoticons. + ';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')), + // ` ...` is an ellipsis, ` .5` a number. + '.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()), + // ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space. + ':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '’' | '!' | '?')), + _ => false, + }; + if flag { + v.push(issue(i, i + 1 + p.len_utf8(), "Spazio prima della punteggiatura".into(), vec![p.to_string()])); + } + } + for (i, _) in text.match_indices(" ") { + if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { + continue; + } + v.push(issue(i, i + 2, "Spazio doppio".into(), vec![" ".into()])); + } +} + +fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + let mut after_end = true; + for (a, b) in ws { + let Some(w) = text.get(*a..*b) else { continue }; + if after_end && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) { + let fixed: String = w.chars().next().map(|f| f.to_uppercase().chain(w.chars().skip(1)).collect()).unwrap_or_default(); + v.push(issue(*a, *b, "Maiuscola a inizio frase".into(), vec![fixed])); + } + after_end = ends_sentence(text.get(*b..).unwrap_or(""), w); + } +} + +fn doubled_punctuation(text: &str, v: &mut Vec) { + for pat in [",,", "..", ";;", "::", "??", "!!"] { + for (i, _) in text.match_indices(pat) { + if pat == ".." && text.get(i + 2..).is_some_and(|r| r.starts_with('.')) { + continue; // ellipsis + } + v.push(issue(i, i + pat.len(), "Punteggiatura doppia".into(), vec![pat[..1].into()])); + } + } +} + +/// Grammar issues in a paragraph of Italian text, in order. +pub fn check(text: &str) -> Vec { + let ws: Vec<(usize, usize)> = words(text); + let mut v = Vec::new(); + repeated_words(text, &ws, &mut v); + spaces(text, &mut v); + sentence_capitals(text, &ws, &mut v); + doubled_punctuation(text, &mut v); + v.sort_by_key(|i| (i.start, i.end)); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn italian_grammar_flags() { + test_dict_install(); + let t = "questo questo è un test , fatto male.. Vedi dott. rossi"; + let v = check(t); + let msgs: Vec<&str> = v.iter().map(|i| i.message.as_str()).collect(); + assert!(msgs.iter().any(|m| m.contains("ripetuta")), "{msgs:?}"); + assert!(msgs.iter().any(|m| m.contains("prima della punteggiatura")), "{msgs:?}"); + assert!(msgs.iter().any(|m| m.contains("doppio")), "{msgs:?}"); + assert!(msgs.iter().any(|m| m.contains("doppia")), "{msgs:?}"); + assert!(v.iter().all(|i| i.lang == ProofLang::It), "{v:?}"); + } + + #[test] + fn italian_grammar_stays_quiet() { + test_dict_install(); + assert!( + check("Vado a Roma. Il dott. Rossi è già lì... piano piano!").is_empty(), + "{:?}", + check("Vado a Roma. Il dott. Rossi è già lì... piano piano!") + ); + assert!(check("Sì sì, bene bene :)").is_empty()); + } + + fn test_dict_install() { + crate::it::test_dict::install(); + } +} diff --git a/crates/proof/src/it/mod.rs b/crates/proof/src/it/mod.rs new file mode 100644 index 000000000..41f63e489 --- /dev/null +++ b/crates/proof/src/it/mod.rs @@ -0,0 +1,189 @@ +//! Italian proofing. +//! +//! - **Spelling** against the Hunspell `it_IT` dictionary installed on the system (found by +//! [`crate::hunspell`]: `it_IT.aff`/`it_IT.dic` from the OS, a package manager or +//! LibreOffice; it isn't bundled — the upstream file is GPL-licensed). Without it Italian +//! text gets no spelling marks. Elided forms check the word after the apostrophe +//! (`l'amico`, `un'idea`, `c'è`); hyphenated compounds are checked part by part; the user +//! dictionary applies as in English. +//! - **Suggestions** tuned for Italian ([`suggest`]): missing accents first (`caffe` → `caffè`, +//! `perche` → `perché`), then the dictionary's `REP` confusions, a missing space, one-letter +//! typos, and a dictionary scan for anything further off. +//! - **Grammar** ([`grammar`]): cheap, reliable rules with Italian messages. + +pub mod grammar; +mod suggest; +#[cfg(test)] +pub(crate) mod test_dict; + +use std::sync::{Arc, RwLock}; + +use crate::affix::AffixDict; + +pub use suggest::suggest; + +/// The Italian dictionary: `None` until first use, then the installed one (or an empty one). +static DICT: RwLock>> = RwLock::new(None); + +/// File names of an Italian Hunspell dictionary (Italy first, then Switzerland). +const NAMES: &[&str] = &["it_IT", "it_CH", "it-IT", "it"]; + +/// The Italian dictionary: the installed Hunspell `it_IT` (read on first use), one given with +/// [`install_dictionary`], or an empty one when none is available (Italian spelling is off +/// then; see [`has_dictionary`]). +pub fn dictionary() -> Arc { + if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + return d.clone(); + } + let loaded = Arc::new(load_installed()); + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + // Another thread (or an install) may have filled it meanwhile: that one wins. + slot.get_or_insert(loaded).clone() +} + +fn load_installed() -> AffixDict { + let Some((aff, dic)) = crate::hunspell::find(NAMES) else { + log::info!("Italian spelling: no Hunspell it_IT dictionary installed"); + return AffixDict::default(); + }; + crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| { + log::warn!("Italian spelling: {e}"); + AffixDict::default() + }) +} + +/// Use this Italian dictionary from now on (Hunspell `.aff` and `.dic` text, decoded to +/// UTF-8), for example one the user picked. On error the current dictionary stays. +pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> { + let d = AffixDict::from_hunspell(aff, dic, &[])?; + *DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + Ok(()) +} + +/// Forget the loaded dictionary, so the next check looks for an installed one again (after +/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). +pub fn reload_dictionary() { + *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; +} + +/// Is an Italian dictionary available (spelling is checked)? +pub fn has_dictionary() -> bool { + !dictionary().is_empty() +} + +/// Is `c` a letter of the Latin script (the only one the Italian dictionary covers)? +fn is_latin_letter(c: char) -> bool { + c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c)) +} + +/// Proclitics (and verb clippings) that elide before a vowel or `h`: the word after the +/// apostrophe is what gets checked (`l'amico`, `un'idea`, `c'è`, `m'hai`). +const PROCLITICS: &[&str] = &[ + "l", "un", "una", "uno", "all", "dall", "dell", "nell", "sull", "coll", "pell", "c", "m", "t", "s", "v", "d", "j", "quest", "quell", "bell", + "sant", "grand", "poc", +]; + +/// Is `word` spelled correctly in Italian? Numbers, single letters, short words in capitals +/// (acronyms: `RAI`, `TG`), words with digits and words in other scripts are accepted, and so +/// is every word when no Italian dictionary is installed. +pub fn is_correct(word: &str) -> bool { + is_correct_with(&dictionary(), word) +} + +/// [`is_correct`] against a given dictionary (an empty one accepts everything). +pub fn is_correct_with(d: &AffixDict, word: &str) -> bool { + if d.is_empty() { + return true; // no Italian dictionary installed: no spelling marks + } + let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’'); + let w = w.trim_matches(['\'', '’']); + let n = w.chars().count(); + if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) { + return true; + } + if w.chars().all(|c| !c.is_lowercase()) && n <= 6 { + return true; + } + if !w.chars().any(is_latin_letter) { + return true; // other scripts: no dictionary + } + if w.contains('-') { + return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); + } + if let Some((head, tail)) = w.split_once(['\'', '’']) + && PROCLITICS.contains(&head.to_lowercase().as_str()) + && !tail.is_empty() + { + return known(d, tail); + } + known(d, w) +} + +/// Is one word (no hyphens, no apostrophe) in the dictionary or the user dictionary? +fn known(d: &AffixDict, w: &str) -> bool { + d.check(w) || crate::in_user_dictionary(&w.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dictionary_loads() { + test_dict::install(); + let d = dictionary(); + assert!(has_dictionary()); + assert!(d.len() > 50, "{}", d.len()); + assert!(d.rule_count() >= 4, "the test dictionary's affix rules"); + assert!(!d.rep().is_empty() && !d.map().is_empty() && !d.try_chars().is_empty()); + } + + #[test] + fn swiss_dictionary_files_are_found() { + // it_CH is searched too, so Swiss users with only it_CH installed get Italian spelling. + let dir = std::env::temp_dir().join(format!("wc-it-ch-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + std::fs::write(dir.join("it_CH.aff"), test_dict::AFF).expect("aff"); + std::fs::write(dir.join("it_CH.dic"), test_dict::DIC).expect("dic"); + let found = crate::hunspell::find_in(&dir, NAMES); + let ok = found.as_ref().is_some_and(|(a, d)| a.ends_with("it_CH.aff") && d.ends_with("it_CH.dic")); + assert!(ok, "{found:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn without_a_dictionary_nothing_is_flagged() { + let none = AffixDict::default(); + for w in ["caffè", "perché", "xqzv", ""] { + assert!(is_correct_with(&none, w), "{w}"); + } + assert!(install_dictionary("FLAG long\n", "1\nkot\n").is_err(), "unsupported files are refused"); + } + + #[test] + fn italian_words_are_correct() { + test_dict::install(); + for w in [ + "casa", "case", "libro", "libri", "città", "caffè", "perché", "più", "può", "parlare", "parla", "parlano", "rifare", "bello", "belli", + "nuovo", "bianco", "Roma", "Milano", "l'amico", "un'idea", "L'amico", "RAI", "2026", "e", "non", + ] { + assert!(is_correct(w), "{w}"); + } + } + + #[test] + fn misspellings_are_flagged() { + test_dict::install(); + for w in ["caffé", "perche", "lbiro", "parlre", "amicaa", "citta", "quelo"] { + assert!(!is_correct(w), "{w}"); + } + } + + #[test] + fn user_dictionary_applies() { + test_dict::install(); + assert!(!is_correct("zxqitalianaparola")); + crate::add_word("Zxqitalianaparola"); + assert!(is_correct("zxqitalianaparola")); + } +} diff --git a/crates/proof/src/it/suggest.rs b/crates/proof/src/it/suggest.rs new file mode 100644 index 000000000..500d1c09d --- /dev/null +++ b/crates/proof/src/it/suggest.rs @@ -0,0 +1,435 @@ +//! Italian spelling suggestions, best first. +//! +//! Candidates come in rounds of rising cost, each checked against the dictionary: +//! 1. letter case (`roma` → `Roma`); +//! 2. missing or wrong accents, from the dictionary's `MAP` groups (`caffe` → `caffè`, +//! `perche` → `perché`), up to four letters changed — Italian typed without accents is the +//! most common slip, so adding one ranks first, while taking away an accent the writer typed +//! ranks after the known confusions; +//! 3. the dictionary's `REP` table of common confusions (`caffé` → `caffè`); +//! 4. a missing space (`perme` → `per me`, `amilano` → `a milano`), cheaper when one half is a +//! short function word; +//! 5. one typo: a letter left out, added, swapped with the next or mistyped (`TRY` letters); +//! 6. when that finds fewer than asked for: the stems that start like the word (accents +//! folded) and share the most letter pairs with it, whose forms are ranked by an edit distance +//! in which an accent slip costs half (two typos at most). +//! +//! The input is bounded (40 letters), and so is every round, so a call costs ten-odd thousand +//! dictionary lookups at most. + +use std::collections::{HashMap, HashSet}; + +use crate::affix::{AffixDict, Casing, capitalize, casing}; + +/// Short words often run into the next one (`per me`, `a Milano`, `di Roma`). +const LEADING_WORDS: &[&str] = &[ + "a", "e", "è", "di", "da", "in", "con", "su", "per", "tra", "fra", "che", "chi", "cui", "non", "si", "ci", "vi", "ne", "più", "meno", "molto", + "tanto", "come", "dove", "quando", "mai", "qui", "qua", "lì", "là", "ogni", "altro", "tutto", "questo", "quello", "bene", "male", "anche", + "ancora", "sempre", "già", "poi", "ed", "od", "ad", "un", "uno", "una", "il", "lo", "la", "i", "gli", "le", "del", "al", "dal", "nel", "sul", + "col", "pel", "allo", "dello", "nello", "sullo", "della", "alla", "dalla", "nella", "sulla", "degli", "agli", "dagli", "negli", "sugli", "delle", + "alle", "dalle", "nelle", "sulle", "dei", "ai", "dai", "nei", "sui", "coi", "dei", +]; +/// Short words often run into the one before (enclitic pronouns: `dimmelo` in writing without +/// the doubling, or a dropped space: `perme` → `per me`). +const TRAILING_WORDS: &[&str] = &[ + "mi", "ti", "si", "ci", "vi", "ne", "lo", "la", "li", "le", "gli", "me", "te", "lui", "lei", "noi", "voi", "loro", "che", "cosa", "come", "dove", + "è", +]; +const MAX_LETTERS: usize = 40; +const MAX_MAP_CHANGES: usize = 4; +const MAX_MAP_VARIANTS: usize = 4096; +/// Stems the scan of round 6 expands. +const SCAN_STEMS: usize = 48; + +const COST_CASE: u32 = 0; +const COST_ADD_DIACRITIC: u32 = 4; +const COST_OTHER_MAP: u32 = 7; +const COST_DROP_DIACRITIC: u32 = 11; +const COST_REP: u32 = 10; +const COST_SPLIT: u32 = 12; +const COST_SPLIT_RARE: u32 = 18; +const COST_SWAP: u32 = 14; +const COST_DROP_OR_ADD: u32 = 15; +const COST_MISTYPE: u32 = 16; +const COST_SCAN: u32 = 20; + +struct Collector<'a> { + d: &'a AffixDict, + /// The word, lower case. + input: String, + tested: HashSet, + /// Suggestion → (cost, order found). + found: HashMap, + seq: usize, +} + +impl Collector<'_> { + /// Check a lower-case candidate (a word, or two separated by a space) and keep it if every + /// word is in the dictionary, as written or capitalized (names). + fn offer(&mut self, cand: &str, cost: u32) { + if cand.is_empty() || cand == self.input || !self.tested.insert(cand.to_string()) { + return; + } + let mut words = Vec::new(); + let mut cost = cost; + for part in cand.split(' ') { + if self.d.contains(part) { + words.push(part.to_string()); + } else { + let c = capitalize(part); + if !self.d.contains(&c) { + return; + } + words.push(c); + // A name ranks after a common word at the same distance. + cost += 1; + } + } + self.keep(words.join(" "), cost); + } + + /// Keep a suggestion known to be correct. + fn keep(&mut self, text: String, cost: u32) { + self.seq += 1; + let seq = self.seq; + let e = self.found.entry(text).or_insert((cost, seq)); + if cost < e.0 { + *e = (cost, seq); + } + } + + fn count_within(&self, cost: u32) -> usize { + self.found.values().filter(|(c, _)| *c <= cost).count() + } +} + +/// The MAP group of each letter (its other members are its likely confusions). +fn alternatives(c: char, map: &[Vec]) -> Vec { + map.iter().filter(|g| g.contains(&c)).flat_map(|g| g.iter().copied()).filter(|&x| x != c).collect() +} + +/// The cost of changing letter `from` to its MAP relative `to`. Adding a missing accent is the +/// commonest slip (Italian typed without accents: `citta`); an accent the writer did type +/// is likelier right, so taking one away costs more than a known confusion from the REP table. +fn map_cost(from: char, to: char) -> u32 { + match (from.is_ascii(), to.is_ascii()) { + (true, false) => COST_ADD_DIACRITIC, + (false, true) => COST_DROP_DIACRITIC, + _ => COST_OTHER_MAP, + } +} + +/// Every way of changing up to [`MAX_MAP_CHANGES`] letters to a MAP relative, with its cost. +fn map_variants(chars: &[char], alts: &[Vec], i: usize, changes: usize, cost: u32, buf: &mut Vec, out: &mut Vec<(String, u32)>) { + if out.len() >= MAX_MAP_VARIANTS { + return; + } + let Some(&c) = chars.get(i) else { + if changes > 0 { + out.push((buf.iter().collect(), cost)); + } + return; + }; + buf.push(c); + map_variants(chars, alts, i + 1, changes, cost, buf, out); + buf.pop(); + if changes < MAX_MAP_CHANGES { + for &a in alts.get(i).map(Vec::as_slice).unwrap_or_default() { + buf.push(a); + map_variants(chars, alts, i + 1, changes + 1, cost + map_cost(c, a), buf, out); + buf.pop(); + } + } +} + +/// A letter's MAP representative (the first of its group), lower case: `à` → `a`. +fn fold(c: char, map: &[Vec]) -> char { + let l = c.to_lowercase().next().unwrap_or(c); + map.iter().find(|g| g.contains(&l)).and_then(|g| g.first().copied()).unwrap_or(l) +} + +/// Optimal string alignment distance where a MAP slip (`a`/`à`, `e`/`è`) costs 1 and any other +/// edit 2; `limit + 1` once it is certainly above `limit`. +fn weighted_distance(a: &[char], b: &[char], related: &dyn Fn(char, char) -> bool, limit: u32) -> u32 { + let m = b.len(); + let over = limit + 1; + let mut prev2 = vec![0u32; m + 1]; + let mut prev: Vec = (0..=m as u32).map(|j| j * 2).collect(); + let mut cur = vec![0u32; m + 1]; + for (i, &ai) in a.iter().enumerate() { + if let Some(c) = cur.get_mut(0) { + *c = (i as u32 + 1) * 2; + } + let mut row_min = u32::MAX; + for (j, &bj) in b.iter().enumerate() { + let sub = if ai == bj { + 0 + } else if related(ai, bj) { + 1 + } else { + 2 + }; + let at = |v: &[u32], k: usize| v.get(k).copied().unwrap_or(u32::MAX / 4); + let mut v = (at(&prev, j + 1) + 2).min(at(&cur, j) + 2).min(at(&prev, j) + sub); + if i > 0 && j > 0 && a.get(i - 1) == Some(&bj) && b.get(j - 1) == Some(&ai) { + v = v.min(at(&prev2, j - 1) + 2); + } + if let Some(c) = cur.get_mut(j + 1) { + *c = v; + } + row_min = row_min.min(v); + } + if m > 0 && row_min > limit { + return over; + } + std::mem::swap(&mut prev2, &mut prev); + std::mem::swap(&mut prev, &mut cur); + } + prev.get(m).copied().unwrap_or(over).min(over) +} + +/// Spelling suggestions for an Italian word, best first, at most `max`, in the word's letter case. +pub fn suggest(word: &str, max: usize) -> Vec { + suggest_in(&super::dictionary(), word, max) +} + +/// [`suggest`] against a given dictionary. +pub fn suggest_in(d: &AffixDict, word: &str, max: usize) -> Vec { + let word = word.trim(); + let n = word.chars().count(); + if n == 0 || n > MAX_LETTERS || max == 0 || d.is_empty() || word.contains(char::is_whitespace) { + return Vec::new(); + } + let case = casing(word); + let input = word.to_lowercase(); + let chars: Vec = input.chars().collect(); + let mut col = Collector { d, input: input.clone(), tested: HashSet::new(), found: HashMap::new(), seq: 0 }; + // 1. Letter case. + if d.contains(&input) && word != input { + col.keep(input.clone(), COST_CASE); + } else if d.contains(&capitalize(&input)) && word != capitalize(&input) { + col.keep(capitalize(&input), COST_CASE); + } + // 2. Accents and other MAP slips. + let map = d.map(); + let alts: Vec> = chars.iter().map(|&c| alternatives(c, map)).collect(); + let mut variants = Vec::new(); + map_variants(&chars, &alts, 0, 0, 0, &mut Vec::with_capacity(n), &mut variants); + variants.sort_by_key(|v| v.1); + for (v, cost) in &variants { + col.offer(v, *cost); + } + // 3. Common confusions. + for (from, to) in d.rep() { + if from.is_empty() { + continue; + } + for (i, _) in input.match_indices(from.as_str()) { + if let (Some(a), Some(b)) = (input.get(..i), input.get(i + from.len()..)) { + col.offer(&format!("{a}{to}{b}"), COST_REP); + } + } + } + // 4. A missing space: cheap after a short leading word or before a short trailing one, + // dearer between two longer words, never leaving a stray short half. + for (i, _) in input.char_indices().skip(1) { + let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue }; + let long = |p: &str| p.chars().count() >= 3; + let cost = if (LEADING_WORDS.contains(&a) && long(b)) || (long(a) && TRAILING_WORDS.contains(&b)) { + COST_SPLIT + } else if long(a) && long(b) { + COST_SPLIT_RARE + } else { + continue; + }; + col.offer(&format!("{a} {b}"), cost); + } + // 5. One typo. + let mut letters: Vec = d.try_chars().chars().filter(|c| c.is_lowercase()).collect(); + if letters.is_empty() { + letters = "aeiortnslcdupmghfbqvàèéìòóùz".chars().collect(); + } + let s = |v: &[char]| v.iter().collect::(); + for i in 0..n.saturating_sub(1) { + if chars.get(i) != chars.get(i + 1) { + let mut v = chars.clone(); + v.swap(i, i + 1); + col.offer(&s(&v), COST_SWAP); + } + } + for i in 0..n { + let mut v = chars.clone(); + v.remove(i); + col.offer(&s(&v), COST_DROP_OR_ADD); + } + for i in 0..=n { + for &c in &letters { + let mut v = chars.clone(); + v.insert(i, c); + col.offer(&s(&v), COST_DROP_OR_ADD); + } + } + for i in 0..n { + for &c in &letters { + if chars.get(i) != Some(&c) { + let mut v = chars.clone(); + if let Some(x) = v.get_mut(i) { + *x = c; + } + col.offer(&s(&v), COST_MISTYPE); + } + } + } + // 6. Further off: scan the stems that start like the word. + if col.count_within(COST_MISTYPE) < max { + scan(&mut col, &chars); + } + let mut found: Vec<(String, (u32, usize))> = col.found.into_iter().collect(); + // Among equal costs, the change nearest the word's start is the likelier intent. + let diff_at = |text: &str| chars.iter().zip(text.chars()).position(|(a, b)| *a != b).unwrap_or(n); + found.sort_by_key(|(text, (cost, seq))| (*cost, diff_at(text), *seq)); + let mut out: Vec = Vec::new(); + for (text, _) in found { + let t = match case { + Casing::Initial => capitalize(&text), + Casing::Upper if n > 1 => text.to_uppercase(), + _ => text, + }; + if t != word && !out.contains(&t) { + out.push(t); + } + if out.len() >= max { + break; + } + } + out +} + +/// Round 6: rank the stems starting with the word's first letter (or a MAP relative of it, either +/// case) by shared letter pairs, then keep the forms of the best within two typos. +fn scan(col: &mut Collector, chars: &[char]) { + let d = col.d; + let map = d.map(); + let Some(&first) = chars.first() else { return }; + let folded: Vec = chars.iter().map(|&c| fold(c, map)).collect(); + let pairs: HashSet<(char, char)> = std::iter::once(' ').chain(folded.iter().copied()).zip(folded.iter().copied()).collect(); + let mut firsts: Vec = std::iter::once(first).chain(alternatives(first, map)).collect(); + let upper: Vec = firsts.iter().flat_map(|c| c.to_uppercase()).collect(); + firsts.extend(upper); + firsts.sort_unstable(); + firsts.dedup(); + let n = folded.len(); + let mut best: Vec<(u32, &str)> = Vec::new(); + for f in firsts { + let prefix = f.to_string(); + for stem in d.stems_with_prefix(&prefix) { + let sf: Vec = stem.chars().map(|c| fold(c, map)).collect(); + let shared = std::iter::once(' ').chain(sf.iter().copied()).zip(sf.iter().copied()).filter(|p| pairs.contains(p)).count(); + if shared < 2 { + continue; + } + // Shared pairs, less the stem's letters the word doesn't explain, less a length gap + // beyond what an ending explains. + let extra = sf.len().saturating_sub(shared); + let gap = sf.len().abs_diff(n).saturating_sub(4); + let score = (shared * 4).saturating_sub(extra * 3 + gap * 2) as u32; + if score == 0 { + continue; + } + if best.len() < SCAN_STEMS { + best.push((score, stem)); + } else if let Some(min) = best.iter_mut().min_by_key(|b| b.0) + && score > min.0 + { + *min = (score, stem); + } + } + } + best.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1))); + let related = |a: char, b: char| fold(a, map) == fold(b, map); + let limit = 4; + for (_, stem) in best { + for form in d.forms(stem) { + let lf: Vec = form.to_lowercase().chars().collect(); + if lf.len().abs_diff(chars.len()) > 2 { + continue; + } + let dist = weighted_distance(&lf, chars, &related, limit); + if dist <= limit && lf.as_slice() != chars { + col.keep(form, COST_SCAN + dist * 3); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn has(word: &str, want: &str) { + let s = suggest(word, 8); + assert!(s.iter().any(|x| x == want), "{word} → {s:?}, want {want}"); + } + + #[test] + fn italian_misspellings_get_the_right_suggestion() { + crate::it::test_dict::install(); + has("caffe", "caffè"); + has("perche", "perché"); + has("cioe", "cioè"); + has("citta", "città"); + has("lbiro", "libro"); + has("parlre", "parlare"); + has("quelo", "quello"); + has("perme", "per me"); + has("amilano", "a Milano"); + has("roma", "Roma"); + has("Caffe", "Caffè"); + } + + #[test] + fn the_likeliest_fix_comes_first() { + crate::it::test_dict::install(); + assert_eq!(suggest("caffe", 3).first().map(String::as_str), Some("caffè")); + assert_eq!(suggest("perche", 3).first().map(String::as_str), Some("perché")); + } + + #[test] + fn edge_cases_stay_empty_or_bounded() { + crate::it::test_dict::install(); + assert!(suggest("", 5).is_empty()); + assert!(suggest("bene", 0).is_empty()); + assert!(suggest(&"a".repeat(41), 5).is_empty()); + assert!(suggest("due parole", 5).is_empty()); + assert!(suggest_in(&crate::affix::AffixDict::default(), "caffe", 5).is_empty()); + assert!(suggest("caffè", 5).len() <= 5); + assert!(!suggest("caffè", 5).contains(&"caffè".to_string())); + } + + #[test] + fn distance_weighs_accents_half() { + let map = vec![vec!['a', 'à'], vec!['e', 'é', 'è']]; + let related = |a: char, b: char| fold(a, &map) == fold(b, &map); + let c = |s: &str| s.chars().collect::>(); + assert_eq!(weighted_distance(&c("caffe"), &c("caffè"), &related, 9), 1); + assert_eq!(weighted_distance(&c("citta"), &c("città"), &related, 9), 1); + assert_eq!(weighted_distance(&c("bene"), &c("bene"), &related, 9), 0); + assert_eq!(weighted_distance(&c("bene"), &c("male"), &related, 2), 3); + assert_eq!(weighted_distance(&c("bene"), &c("benissimo"), &related, 2), 3); + assert_eq!(weighted_distance(&c(""), &c("ab"), &related, 9), 4); + } + + #[test] + fn suggestions_are_fast_enough() { + crate::it::test_dict::install(); + // Warm the dictionary, then time a worst case (a long word nothing near): the scan round. + let _ = suggest("bene", 1); + let t = std::time::Instant::now(); + for w in ["xqzvweitalianaparola", "prsidenti", "parallellepipedo"] { + let _ = suggest(w, 6); + } + let ms = t.elapsed().as_secs_f64() * 1000.0; + eprintln!("3 suggestion calls: {ms:.0} ms"); + assert!(ms < 20_000.0, "{ms} ms"); + } +} diff --git a/crates/proof/src/it/test_dict.rs b/crates/proof/src/it/test_dict.rs new file mode 100644 index 000000000..16fd8f649 --- /dev/null +++ b/crates/proof/src/it/test_dict.rs @@ -0,0 +1,138 @@ +//! A tiny synthetic Italian Hunspell dictionary for tests (written for these tests; not taken +//! from any real dictionary). Real dictionaries are installed on the system, never bundled. +//! +//! It exercises what the Italian dictionaries use: the default single-character `FLAG` type, +//! suffixes (verb endings `-are`, noun plurals `-o` → `-i`), a prefix (`ri-`), the suggestion +//! tables (`TRY`, `MAP` for accents, `REP` for common confusions) and elided forms with an +//! apostrophe (`l'amico`). + +pub const AFF: &str = "SET UTF-8 +TRY aeiortnslcdupmghfbqvàèéìòóùz +MAP 5 +MAP aà +MAP eéè +MAP iì +MAP oòó +MAP uù +REP 3 +REP caffé caffè +REP perche perché +REP cioe cioè +SFX A Y 3 +SFX A 0 are . +SFX A 0 a . +SFX A 0 ano . +SFX N Y 2 +SFX N o i o +SFX N a e a +PFX R Y 1 +PFX R 0 ri . +"; + +/// Stems (the count line first, as in Hunspell files). +pub const DIC: &str = "96 +città +caffè +perché +più +può +è +già +così +virtù +tribù +menù +tè +Mosca +Roma +Milano +Torino +casa/N +libro/N +cane +gatto +sole +luna +mare +amico +amica +idea +parl/A +mangiare +dormire +fare/R +scuola +bambino/N +albero +fiore +pane +acqua +terra +cielo +notte +giorno +vecchia +vado +con +ha +ho +bel +cioè +la +un +in +piazza +lavoro +tempo +uomo +donna +bello/N +buono +grande +piccolo +nuovo/N +vecchio +bianco +nero +rosso +verde +giallo +uno +due +tre +tutto +come +quando +dove +senza +sopra +sotto +tra +molto +sempre +mai +qui +lì +sì +no +non +che +chi +cui +mio +tuo +suo +nostro +a +per +me +quello +"; + +/// Make [`AFF`]/[`DIC`] the Italian dictionary (every test that needs one calls this; they all +/// install the same one, so tests running in parallel agree). +pub fn install() { + if let Err(e) = super::install_dictionary(AFF, DIC) { + panic!("test dictionary: {e}"); + } +} diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index 21d99b9aa..465913bb7 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -21,6 +21,9 @@ pub enum ProofLang { /// friends, when there is one), the Portuguese grammar rules and the Portuguese hyphenation /// patterns. Pt, + /// Italian (`it`, `it-IT`, `it-CH`): the installed Hunspell dictionary (`it_IT`, when there + /// is one), the Italian grammar rules and the Italian hyphenation patterns. + It, /// A language without proofing tools here (`fr-FR`, `ja-JP`, `x-none`…). Like Word without /// the language's proofing tools: no spelling or grammar marks, so its words are never /// flagged as English misspellings. Hyphenation keeps using the English patterns, as before @@ -30,7 +33,7 @@ pub enum ProofLang { impl ProofLang { /// Every language with a spelling dictionary, in display order. - pub const PROOFED: [ProofLang; 3] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt]; + pub const PROOFED: [ProofLang; 4] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It]; /// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). Only the primary subtag /// counts. No tag (or an empty one) is English, WordCraft's default editing language. @@ -45,6 +48,8 @@ impl ProofLang { ProofLang::Bg } else if primary.eq_ignore_ascii_case("pt") || primary.eq_ignore_ascii_case("por") { ProofLang::Pt + } else if primary.eq_ignore_ascii_case("it") || primary.eq_ignore_ascii_case("ita") { + ProofLang::It } else { ProofLang::Other } @@ -57,6 +62,7 @@ impl ProofLang { ProofLang::Pl => "pl", ProofLang::Bg => "bg", ProofLang::Pt => "pt", + ProofLang::It => "it", ProofLang::Other => "", } } @@ -133,6 +139,9 @@ mod tests { for t in ["pt", "pt-BR", "PT_br", "pt-BR.UTF-8", " pt-PT ", "por"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Pt, "{t}"); } + for t in ["it", "it-IT", "IT_it", "it-CH", "it-CH.UTF-8", " it-IT ", "ita"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::It, "{t}"); + } for t in ["en", "en-US", "en-GB", "EN_au", "en-Latn-US"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::En, "{t}"); } @@ -152,6 +161,8 @@ mod tests { assert!(ProofLang::Pt.is_proofed() && !ProofLang::Other.is_proofed()); assert_eq!(ProofLang::Pl.code(), "pl"); assert_eq!(ProofLang::Pt.code(), "pt"); + assert!(ProofLang::It.is_proofed()); + assert_eq!(ProofLang::It.code(), "it"); assert!(ProofLang::PROOFED.contains(&ProofLang::Pt), "every proofed language is listed"); } } diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 075f3b43c..adfcf1eba 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -18,6 +18,11 @@ //! dictionary installed on the system (VERO and friends; not bundled), suggestions tuned for //! Portuguese (missing accents, `seje`/`seja` confusions, missing spaces), conservative //! grammar rules with Portuguese messages, and `hyph-utf8`'s Portuguese patterns. +//! - **Italian** ([`ProofLang::It`], module [`it`]): spelling against the Hunspell `it_IT` +//! dictionary installed on the system (not bundled — the upstream file is GPL-licensed), +//! suggestions tuned for Italian (missing accents, `REP` confusions, missing spaces), +//! conservative grammar rules with Italian messages, and `hyph-utf8`'s Italian patterns +//! (Beccari, MIT option). //! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without //! the language's proofing tools. //! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese), @@ -31,6 +36,7 @@ pub mod bg; pub mod dict; pub mod hunspell; pub mod hyphen; +pub mod it; pub mod lang; pub mod patterns; pub mod pl; @@ -499,6 +505,7 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option let message = match lang { ProofLang::Pl => "Możliwy błąd pisowni", ProofLang::Pt => "Possível erro de ortografia", + ProofLang::It => "Possibile errore di ortografia", _ => "Possible spelling mistake", }; Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang }) @@ -515,6 +522,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { ProofLang::En => is_correct_with(word, opts), ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word), ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word), + ProofLang::It => accepted_by_options(word, opts) || it::is_correct(word), ProofLang::Bg | ProofLang::Other => true, } } @@ -525,18 +533,20 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { ProofLang::En => suggest(word, max), ProofLang::Pl => pl::suggest(word, max), ProofLang::Pt => pt::suggest(word, max), + ProofLang::It => it::suggest(word, max), ProofLang::Bg | ProofLang::Other => Vec::new(), } } /// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones -/// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), or none for a language WordCraft -/// doesn't proof. +/// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), the Italian ones ([`it::grammar`]), +/// or none for a language WordCraft doesn't proof. pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { match lang { ProofLang::En => check_grammar(text), ProofLang::Pl => pl::grammar::check(text), ProofLang::Pt => pt::grammar::check(text), + ProofLang::It => it::grammar::check(text), ProofLang::Bg | ProofLang::Other => Vec::new(), } } @@ -989,6 +999,21 @@ mod tests { assert!(!g.iter().any(|i| i.message.contains("\"an\"")), "{g:?}"); } + #[test] + fn italian_uses_its_own_tools() { + it::test_dict::install(); + let it = "La città vecchia ha un bel caffè in piazza."; + assert!(check_spelling_in(it, ProofLang::It).is_empty(), "{:?}", check_spelling_in(it, ProofLang::It)); + assert!(check_spelling_in(it, ProofLang::En).len() >= 3, "Italian words are English misspellings"); + let v = check_spelling_in("Vado a Roma con lbiro.", ProofLang::It); + assert_eq!(flagged("Vado a Roma con lbiro.", &v), ["lbiro"]); + assert_eq!((v[0].lang, v[0].message.as_str()), (ProofLang::It, "Possibile errore di ortografia")); + assert!(suggest_in("caffe", 5, ProofLang::It).contains(&"caffè".to_string())); + // Italian grammar speaks Italian and has no English a/an rule. + let g = check_grammar_in("questo questo è un test", ProofLang::It); + assert!(g.iter().any(|i| i.message.contains("ripetuta")), "{g:?}"); + } + #[test] fn mixed_language_paragraphs_check_each_run_in_its_language() { pl::test_dict::install(); @@ -1014,6 +1039,12 @@ mod tests { let v = check_text(t, &[(0..t.len(), ProofLang::Pt)]); assert_eq!(flagged(t, &v), ["coraçao", "frequencia"], "{v:?}"); assert!(v.iter().all(|i| i.lang == ProofLang::Pt && i.message == "Possível erro de ortografia"), "{v:?}"); + // Italian checks in its span only, with the Italian message. + it::test_dict::install(); + let t = "Vado a Roma con lbiro e caffe."; + let v = check_text(t, &[(0..t.len(), ProofLang::It)]); + assert_eq!(flagged(t, &v), ["lbiro", "caffe"], "{v:?}"); + assert!(v.iter().all(|i| i.lang == ProofLang::It && i.message == "Possibile errore di ortografia"), "{v:?}"); } proptest::proptest! { @@ -1025,7 +1056,7 @@ mod tests { let _ = has_dictionary(Some(&s)); let _ = is_correct_for(&s, Some(&s)); let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default()); - for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::Other] { + for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Other] { let _ = check_spelling_in(&s, lang); let _ = check_grammar_in(&s, lang); let _ = is_correct_in(&s, lang); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index cfabf15ad..328a5c6d7 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -119,8 +119,16 @@ pub static LANGUAGES: [LangInfo; 19] = [ // Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here. LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, - // Italian; `it-IT`, `it-CH` and `it-*` resolve here. - LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + // Italian; `it-IT`, `it-CH` and `it-*` resolve here. New documents are written in + // Italian: `wordcraft-proof` checks it (`ProofLang::It`). + LangInfo { + code: "it", + name: "Italiano", + source: include_str!("it.tsv"), + prefer_hans: false, + editing_lang: Some("it-IT"), + catalog: OnceLock::new(), + }, // Hungarian; `hu`, `hu-HU` and `hu_HU.UTF-8` resolve here. LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // French; `fr`, `fr-FR`, `fr_CA` and other regions resolve here. diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 6c1802974..b35210c88 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1001,10 +1001,30 @@ fn a_polish_interface_writes_new_documents_in_polish() { assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); } +#[test] +fn an_italian_interface_writes_new_documents_in_italian() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas(); + frame(&mut app); + app.run("ui.language", serde_json::json!({"value": "it"})).unwrap(); + frame(&mut app); + assert_eq!(app.session.editing_lang.as_deref(), Some("it-IT")); + assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("it-IT"), "the untouched first document follows"); + // Typing Italian then shows no English misspellings; the status bar names the language. + app.session.run("text.insert", &serde_json::json!({"text": "Città vecchie e caffè"})).unwrap(); + let issues = app.session.run("review.issues", &serde_json::json!({})).unwrap(); + assert!(issues.as_array().unwrap().iter().all(|i| i["lang"] == "it"), "{issues}"); + set_current(lang("it")); + assert_eq!(crate::ribbon::caret_language(&app), "Italiano"); + set_current(Lang::EN); + assert_eq!(crate::ribbon::caret_language(&app), "Italian"); +} + #[test] fn the_editing_languages_are_the_ones_wordcraft_can_proof() { let with: Vec<(&str, &str)> = Lang::all().filter_map(|l| l.editing_lang().map(|e| (l.code(), e))).collect(); - assert_eq!(with, [("pt-br", "pt-BR"), ("pl", "pl-PL")]); + assert_eq!(with, [("pt-br", "pt-BR"), ("pl", "pl-PL"), ("it", "it-IT")]); } #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f558bd7ff..cc22e3350 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -410,8 +410,13 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) } /// The proofing languages the Language menu offers: tag and English name (translated when drawn). -pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 4] = - [("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish"), ("pt-BR", "Portuguese (Brazil)")]; +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 5] = [ + ("en-US", "English (United States)"), + ("en-GB", "English (United Kingdom)"), + ("pl-PL", "Polish"), + ("pt-BR", "Portuguese (Brazil)"), + ("it-IT", "Italian"), +]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's /// default, US English; a tag without a name here: the tag). @@ -423,6 +428,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), + wordcraft_engine::proof::ProofLang::It => "Italian".into(), wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), _ => tag.to_string(), } @@ -445,7 +451,8 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { for (tag, name) in PROOFING_LANGUAGES { let on = current.eq_ignore_ascii_case(tag) || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")) - || (tag == "pt-BR" && current.eq_ignore_ascii_case("pt")); + || (tag == "pt-BR" && current.eq_ignore_ascii_case("pt")) + || (tag == "it-IT" && current.eq_ignore_ascii_case("it")); mi_check(ui, app, name, on, "review.language", json!({"lang": tag})); } ui.separator(); diff --git a/docs/gaps.md b/docs/gaps.md index f091bbfe7..d80f68cdb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 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. | B | 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**: 54 modal dialogs vs Word's ~100 | 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) | -| 8 | **Proofing covers English, Polish and Portuguese only**; no dictionaries, grammar or hyphenation for the other ~47 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish and Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish and Portuguese | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Proofing covers English, Polish, Portuguese and Italian only**; no dictionaries, grammar or hyphenation for the other ~46 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12) and Italian (it-IT via the installed Hunspell dictionary), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish, Portuguese and Italian | 20–40 | [localization-parity.md](localization-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), 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 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~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) | @@ -73,6 +73,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | | 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | | 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps | | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e8d62f1ca..7b7f654bf 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,11 +1,11 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Turkish interface catalog completed, 2,325 entries, #774; previously minor: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. -**Dimension: ~28% (estimated; 27% before Portuguese proofing, 25% before Polish and per-run proofing languages), 70–115 h to full +**Dimension: ~29% (estimated; 28% before Italian proofing, 27% before Portuguese proofing, 25% before Polish and per-run proofing languages), 70–115 h to full for the twelve key languages** plus ~45 h for the other ~17 languages Word ships, plus native-speaker review (human). @@ -29,7 +29,7 @@ native-speaker review (human). Read Aloud, Backstage greetings) aren't wrapped in `tl!` yet, so they show in English in every language: the true share of visible strings is **~93–95%** (estimated). - Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). -- **Proofing: English, Polish and Portuguese**, chosen per run from its language (`w:lang`, resolved through +- **Proofing: English, Polish, Portuguese and Italian**, chosen per run from its language (`w:lang`, resolved through the styles; Review › Language and the status bar set it). Languages without proofing tools get no spelling or grammar marks (as in Word without the language's tools) instead of being flagged as English. Detail in [Proofing per language](#proofing-per-language). @@ -84,6 +84,7 @@ documents in Polish (`pl-PL`). | English (`en`, any region; text with no language) | Moby word list (public domain, ~160k words) with inflection rules | edit distance | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic | | Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, or the `WORDCRAFT_DICTIONARIES` folder; not bundled, so no Polish spelling marks without it), read at runtime by a Hunspell-style affix engine (`crates/proof/src/affix.rs`); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet | | Portuguese (`pt`, any region) | The Hunspell `pt_BR` dictionary installed on the system — VERO and its derivatives (same places as Polish; not bundled, LGPL/MPL), read at runtime by the same affix engine, which reads its `FLAG UTF-8`, 106 flags, continuation classes and `FORBIDDENWORD` spellings; names capitalized, compounds part by part (`guarda-roupa`, `ex-marido`) | missing accents first (`coraçao` → `coração`, `saude` → `saúde`), the dictionary's confusion table (`seje` → `seja`, `excessão` → `exceção`), missing space (`nãoé` → `não é`), one typo, then an accent-folding stem scan | Portuguese messages: repeated word (the emphatic doubles are allowed), space before punctuation, double space, sentence capital (knows `etc.`, `pág.`, `Dr.`, initials, ordinals, dialogue dashes), doubled punctuation; no comma rule — `que` opens a clause only sometimes | TeX Portuguese patterns (Rezende, Almeida, Araujo, Benevides; BSD 3-clause) with their 2 exception words | +| Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | | Any other | — (no marks) | — | — | the English patterns, as before | Measured with the SJP.PL dictionary: every one of the ~4.5 million forms an independent expansion of its affix file @@ -158,6 +159,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian proofing: it-IT spelling from the installed Hunspell dictionary (system `it_IT`, never bundled — upstream is GPL), accent-first suggestions, conservative grammar with Italian messages, TeX Italian hyphenation patterns (MIT option); Review › Language already offered Italian; an `it` interface writes it-IT documents | | 2026-10-11 | minor | Turkish interface catalog completed (2,325 entries, the same keys as Russian): equation gallery, built-in style names, watermarks, paper sizes and preview tiles; the interface audit checks Turkish too (#774) | | 2026-10-12 | trivial | Region conventions: File › Options › Advanced › Region (saved) and an Automatic units entry; Unix `LC_PAPER`/`LC_MEASUREMENT`/`LC_NUMERIC` each decide their own convention; the web reads the browser's locale; `WORDCRAFT_NO_PREFS` runs stay en-US; where the decimal separator comes from (#838 review) | | 2026-10-12 | minor | New blank documents follow the system region: A4 outside the Letter territories, centimetres outside the US, the region's decimal separator in length fields; explicit Measurement units choice wins (#838) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2ff414d68..baa2d3c97 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -66,7 +66,7 @@ full parity for the area. | Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | | References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources not yet in DOCX (#169 open). | | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | -| Proofing (spelling, grammar, thesaurus, languages) | 5 | 42% | (in Review) | 20–40 | English, Polish and Portuguese, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10); Portuguese (pt-BR) against the installed VERO Hunspell dictionary — the affix engine reads its `FLAG UTF-8` flags, continuation classes and forbidden spellings — with accent-first suggestions, conservative grammar rules and TeX patterns (2026-10-12). Other languages are left unmarked. The custom dictionary persists across sessions (#40). Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | +| Proofing (spelling, grammar, thesaurus, languages) | 5 | 44% | (in Review) | 20–40 | English, Polish, Portuguese and Italian, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10); Portuguese (pt-BR) against the installed VERO Hunspell dictionary — the affix engine reads its `FLAG UTF-8` flags, continuation classes and forbidden spellings — with accent-first suggestions, conservative grammar rules and TeX patterns (2026-10-12); Italian (it-IT) against the installed Hunspell dictionary through the same affix engine, with accent-first suggestions and conservative grammar rules. Other languages are left unmarked. The custom dictionary persists across sessions (#40). Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. | | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian proofing: it-IT spelling, accent-first suggestions and conservative grammar; Proofing row updated, 42% → 44% | | 2026-10-11 | trivial | Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: column and row markers on the rulers (#700); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: the table context menu, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); Tables row updated, no percentage change | From fd152762b32546895ebb8c87b2377d30a3c13496 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:52:53 +0200 Subject: [PATCH 007/199] Table selection arrow stays visible on dark paper (#1078) (#1095) --- crates/ui-egui/src/table_mouse.rs | 37 ++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/table_mouse.rs b/crates/ui-egui/src/table_mouse.rs index b28940475..f98c08fa7 100644 --- a/crates/ui-egui/src/table_mouse.rs +++ b/crates/ui-egui/src/table_mouse.rs @@ -136,6 +136,7 @@ pub fn cursor(app: &WordApp, ui: &Ui, resp: &egui::Response, layout: &DocLayout, /// dragged, a dashed outline of where the table goes. pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLayout, rects: &[Rect], scale: f32) { let hover = painter.ctx().input(|i| i.pointer.hover_pos()); + let dark = app.session.view.dark_mode; if let Some(d) = &app.canvas.table_drag { match d.target.spot { Spot::Handle => { @@ -150,7 +151,7 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay } spot => { if let Some(p) = hover { - arrow(painter, p, spot); + arrow(painter, p, spot, dark); } } } @@ -177,7 +178,7 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay insert_button(painter, c, a, b, sc, t); } } - None if target.spot != Spot::Handle => arrow(painter, p, target.spot), + None if target.spot != Spot::Handle => arrow(painter, p, target.spot, dark), None => {} } } @@ -212,13 +213,10 @@ fn insert_button(painter: &egui::Painter, c: Pos2, from: Pos2, to: Pos2, sc: f32 /// The selection arrow at the pointer, its tip on it (the system pointer is hidden there): down /// over a column; up and right, filled, in a cell; up and right, hollow, beside a row. -fn arrow(painter: &egui::Painter, tip: Pos2, spot: Spot) { +fn arrow(painter: &egui::Painter, tip: Pos2, spot: Spot, dark: bool) { use std::f32::consts::{FRAC_PI_4, PI}; - let (turn, fill, edge) = match spot { - Spot::Column(_) => (PI, Color32::BLACK, Color32::WHITE), - Spot::Cell(..) => (FRAC_PI_4, Color32::BLACK, Color32::WHITE), - _ => (FRAC_PI_4, Color32::WHITE, Color32::BLACK), - }; + let turn = if matches!(spot, Spot::Column(_)) { PI } else { FRAC_PI_4 }; + let (fill, edge) = arrow_colors(spot, dark); // An arrow pointing up from its tip at (0, 0), turned clockwise by `turn` (screen points). let (sin, cos) = turn.sin_cos(); let at = |(x, y): (f32, f32)| tip + vec2(x * cos - y * sin, x * sin + y * cos); @@ -230,11 +228,34 @@ fn arrow(painter: &egui::Painter, tip: Pos2, spot: Spot) { painter.add(Shape::closed_line(outline.to_vec(), Stroke::new(1.0, edge))); } +/// The arrow's fill and edge: black on white over a column or cell (white on the dark paper's +/// grey in dark mode, where black would vanish), white on black beside a row. +fn arrow_colors(spot: Spot, dark: bool) -> (Color32, Color32) { + match spot { + Spot::Column(_) | Spot::Cell(..) if dark => (Color32::WHITE, Color32::from_gray(wordcraft_render::DARK_PAPER)), + Spot::Column(_) | Spot::Cell(..) => (Color32::BLACK, Color32::WHITE), + _ => (Color32::WHITE, Color32::BLACK), + } +} + #[cfg(test)] mod tests { use super::*; use crate::table_resize::tests::{H, button, cell, screen, steps, table_window, table_window_stepping}; + /// On the dark paper the column and cell arrows are drawn light, so they stay visible; on + /// white paper they stay black, and the hollow row arrow is the same on both. + #[test] + fn the_selection_arrow_is_light_on_dark_paper() { + let dark_paper = Color32::from_gray(wordcraft_render::DARK_PAPER); + assert_eq!(arrow_colors(Spot::Column(0), false), (Color32::BLACK, Color32::WHITE)); + assert_eq!(arrow_colors(Spot::Cell(0, 0), false), (Color32::BLACK, Color32::WHITE)); + assert_eq!(arrow_colors(Spot::Column(0), true), (Color32::WHITE, dark_paper)); + assert_eq!(arrow_colors(Spot::Cell(0, 0), true), (Color32::WHITE, dark_paper)); + assert_eq!(arrow_colors(Spot::Row(0), false), (Color32::WHITE, Color32::BLACK)); + assert_eq!(arrow_colors(Spot::Row(0), true), (Color32::WHITE, Color32::BLACK)); + } + fn click(h: &mut H, at: Pos2) { h.event(egui::Event::PointerMoved(at)); h.step(); From aa91e467532769f8c9d390cc5447125f543af320 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:52:59 +0200 Subject: [PATCH 008/199] Shapes: the drag preview shows the shape alone, not what was behind it (#1097) --- crates/layout/src/display.rs | 17 +++++ crates/render/src/lib.rs | 21 ++++++ crates/ui-egui/src/canvas.rs | 5 -- crates/ui-egui/src/objects.rs | 122 +++++++++++++++++++++++++++++----- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 8a98d5a43..ac023b91a 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -390,6 +390,23 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec, opts: &DisplayOptions) -> Vec { + let mut out = Vec::new(); + object_draws(o, rect, Spin::default(), 1.0, opts, &mut out); + let Some(id) = text_box else { return out }; + for items in [&page.items, &page.header, &page.footer] { + let lines: Vec<&Placed> = items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if *s == id)).collect(); + match warped_boxes(doc, items).into_iter().find(|(w, ..)| *w == id) { + Some((_, rect, warp)) if !lines.is_empty() => warped_text(doc, &lines, rect, &warp, opts, 1.0, &mut out), + _ => lines.into_iter().for_each(|it| item(doc, it, opts, 1.0, &mut out)), + } + } + out +} + /// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their /// story, frame and warp. Empty, without a walk of the document, when none is. fn warped_boxes(doc: &Document, items: &[Placed]) -> Vec<(u32, Rect, wordcraft_doc::wordart::TextWarp)> { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 31c8d8844..b41ed2fa4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -940,6 +940,27 @@ pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32, render_region(doc, page, pw, ph, view, opts) } +/// Draw `items` (page points) alone, on nothing, over the page area (`x`, `y`, `w`×`h` points) +/// 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 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 { + draw(&mut ctx, doc, it, view, &visible, opts); + } + ctx.flush(); + let mut pixels = vec![0u8; w16 as usize * h16 as usize * 4]; + let mut res = Resources::new(); + if let Some(pm) = vello_cpu::PixmapMut::new(w16, h16, &mut pixels) { + ctx.render(pm, &mut res); + } + Rendered { width: w16 as u32, height: h16 as u32, pixels } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8c4012d5c..cef25186c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -138,11 +138,6 @@ pub struct CanvasState { } impl CanvasState { - /// The cached rendering of a page. - pub(crate) fn page_texture(&self, page: usize) -> Option<&TextureHandle> { - self.textures.get(&page).map(|(_, t)| t) - } - /// How many pages have a cached rendering. #[cfg(test)] pub(crate) fn texture_count(&self) -> usize { diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 044784b49..ee771afc9 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -4,8 +4,9 @@ //! `arrange.bounds` / `arrange.rotation` / `arrange.nudge`, so they're undoable and scriptable like //! everything else. //! -//! While dragging, nothing is laid out or rendered: the preview is the object's own pixels taken -//! from its page's cached texture. The drop is one command, one relayout. +//! While dragging, nothing is laid out: the preview is the object drawn alone, once, when the +//! drag starts (#1010: a cut of the page would carry along whatever is behind it). The drop is +//! one command, one relayout. //! //! A selected connector shows a handle at each end instead of a frame: dragging one onto a shape //! glues it to the nearest connection site there (`shape.connect`), elsewhere leaves it free. @@ -38,6 +39,8 @@ pub struct ObjectDrag { picture: bool, /// Dragging a connector's end (false: its start, true: its end) to this page point. end: Option<(bool, (f32, f32))>, + /// The object drawn alone over its rotated bounds, for the preview: made once it moves. + preview: Option, } /// How near (screen points) a press must be to a connector's end to grab it. @@ -161,6 +164,9 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] // Pictures keep their proportions from a corner unless Shift; shapes only with Shift. d.drag.update(at, pages, scale, d.min, d.picture != shift); } + if d.drag.moved && d.preview.is_none() && shows_pixels(&d) { + d.preview = preview(app, ui.ctx(), layout, &d.object, scale); + } app.canvas.obj_drag = Some(d); } else { drop(app, &d); @@ -179,7 +185,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] // A selected connector's end: drag it to glue it. if pressed && let Some((object, which)) = connector_end_at(app, layout, pages, scale, at) { let drag = Drag::new(Grab::Move, object.page, object.rect, 0.0, at); - app.canvas.obj_drag = Some(ObjectDrag { drag, object, min: 0.0, picture: false, end: Some((which, (0.0, 0.0))) }); + app.canvas.obj_drag = Some(ObjectDrag { drag, object, min: 0.0, picture: false, end: Some((which, (0.0, 0.0))), preview: None }); return true; } let Some((object, grab)) = grab_at(app, layout, pages, scale, at) else { return false }; @@ -199,8 +205,14 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] }; // Objects in table cells can be selected and resized, not moved; charts neither. if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) { - app.canvas.obj_drag = - Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at), object, min, picture, end: None }); + app.canvas.obj_drag = Some(ObjectDrag { + drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at), + object, + min, + picture, + end: None, + preview: None, + }); } } true @@ -268,6 +280,26 @@ fn movable(app: &WordApp, o: &ObjectHit) -> bool { o.path.depth() == 0 && selectable_story(app, o.story) } +/// Whether a drag shows the object's pixels (moved, or scaled for an unturned picture) rather +/// than an outline: resizing reflows a text box (and redraws a shape). +fn shows_pixels(d: &ObjectDrag) -> bool { + (d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move +} + +/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points +/// per page point). +fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option { + let obj = app.session.doc.para_at(&o.pos()).and_then(|p| p.object_at(o.off))?; + let page = layout.pages.get(o.page)?; + let mut opts = crate::canvas::screen_render_options(); + opts.dark = app.session.view.dark_mode; + let items = wordcraft_layout::display::object_display(&app.session.doc, page, obj, o.rect, o.text_box, &opts.display); + let b = o.bounds(); + let img = wordcraft_render::render_items(&app.session.doc, &items, b.x, b.y, b.w, b.h, scale * ctx.pixels_per_point(), &opts); + let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight()); + Some(ctx.load_texture("object-drag", ci, egui::TextureOptions::LINEAR)) +} + /// Commit a finished drag as one command. fn drop(app: &mut WordApp, d: &ObjectDrag) { if !d.drag.moved { @@ -314,21 +346,15 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p frame::paint_turning(painter, t, to, d.drag.deg); return; } - // The object's own pixels, from its page's cached texture: moved, or scaled for an - // unturned picture. Resizing reflows a text box (and redraws a shape), so that's just the - // new outline. - let turned = !d.object.spin.is_identity(); - let pixels = (d.picture && !turned) || d.drag.grab == Grab::Move; - if !pixels { + if !shows_pixels(d) { frame::paint_outline(painter, t, to, d.drag.deg); - } else if let (Some(tex), Some(pg)) = (app.canvas.page_texture(d.object.page), layout.pages.get(d.object.page)) { + } else if let Some(tex) = &d.preview { // A turned object's pixels fill its rotated bounds, which move with it. let r = d.object.bounds(); let (dx, dy) = (d.drag.rect.x - d.drag.start.x, d.drag.rect.y - d.drag.start.y); let moved = wordcraft_geom::Rect::new(r.x + dx, r.y + dy, r.w, r.h); - let to = if turned { screen(pages, scale, d.drag.to_page, moved).unwrap_or(to) } else { to }; - let (w, h) = (pg.w.max(1.0), pg.h.max(1.0)); - let uv = Rect::from_min_max(egui::pos2(r.x / w, r.y / h), egui::pos2(r.right() / w, r.bottom() / h)); + let to = if d.object.spin.is_identity() { to } else { screen(pages, scale, d.drag.to_page, moved).unwrap_or(to) }; + let uv = Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); painter.image(tex.id(), to, uv, Color32::from_white_alpha(PREVIEW_OPACITY)); } frame::paint(painter, t, to, d.drag.deg, false, false, false); @@ -470,4 +496,70 @@ mod tests { let (app, hit) = app_with(shape); assert!(!fixed(&app, &hit)); } + + /// #1010: dragging a shape off text, the preview is the shape alone, not the text that was + /// behind it. An unfilled red rectangle in front of black text is dragged down into the empty + /// page: its preview has its red outline and nothing dark inside. + #[test] + fn a_dragged_shape_leaves_the_text_behind_it() { + use crate::table_resize::tests::{H, button, steps}; + let text = "MMMMMMMMMMMM ".repeat(12); + let mut a = WordApp::new(wordcraft_engine::Session::new(Document::from_text(&text)), Default::default()); + let shape = InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 120.0, + h: 60.0, + fill: None, + stroke: Some(wordcraft_doc::Rgb(0xFF, 0, 0)), + stroke_width: 3.0, + float: wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::InFrontOfText, ..Default::default() }, + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + a.session.doc.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); + let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + steps(&mut h, 6); + let o = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("laid out"); + let at = |h: &mut H, x: f32, y: f32| crate::canvas::page_to_screen(h.state_mut(), 0, x, y).unwrap(); + let down = 200.0; + let start = at(&mut h, o.rect.x + o.rect.w / 2.0, o.rect.y + 4.0); + let end = at(&mut h, o.rect.x + o.rect.w / 2.0, o.rect.y + 4.0 + down); + h.event(egui::Event::PointerMoved(start)); + h.step(); + button(&h, start, true); + h.step(); + for k in 1..=5 { + h.event(egui::Event::PointerMoved(start + (end - start) * (k as f32 / 5.0))); + h.step(); + } + h.step(); // the preview is painted a frame after the move + assert!(h.state().canvas.obj_drag.as_ref().is_some_and(|d| d.drag.moved), "a drag is in progress"); + let (tl, br) = (at(&mut h, o.rect.x, o.rect.y + down), at(&mut h, o.rect.right(), o.rect.bottom() + down)); + let img = h.render().unwrap(); + let ppp = h.ctx.pixels_per_point(); + // Pixels in a screen area, but for the mouse pointer drawn at `end`. + let count = |x0: f32, y0: f32, x1: f32, y1: f32, f: &dyn Fn([u8; 4]) -> bool| { + let (x0, y0, x1, y1) = ((x0 * ppp).ceil() as u32, (y0 * ppp).ceil() as u32, (x1 * ppp) as u32, (y1 * ppp) as u32); + let pointer = Rect::from_min_size(end - egui::vec2(2.0, 2.0), egui::vec2(20.0, 26.0)); + (y0..y1) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| !pointer.contains(egui::pos2(x as f32 / ppp, y as f32 / ppp)) && f(img.get_pixel(x, y).0)) + .count() + }; + let red = count(tl.x - 4.0, tl.y - 4.0, br.x + 4.0, br.y + 4.0, &|[r, g, b, _]| r > 200 && g < 180 && b < 180); + assert!(red > 50, "the preview shows the shape's outline ({red} red pixels)"); + let dark = count(tl.x + 8.0, tl.y + 8.0, br.x - 8.0, br.y - 8.0, &|[r, g, b, _]| r < 180 && g < 180 && b < 180); + assert_eq!(dark, 0, "no text from behind the shape in its preview"); + button(&h, end, false); + steps(&mut h, 3); + } } From 4fe989157224b8ca4d8cd2bfe16dc879ab1de6bc Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:53:05 -0500 Subject: [PATCH 009/199] LaTeX: a "[" after a line break or at the start of a list item stays text (#1099) * Contributors: add Jason Brashear to people.toml * LaTeX: a "[" after a line break or at the start of a list item stays text --- contributors/people.toml | 4 ++++ crates/formats/src/latex.rs | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 072e5a8c9..a10cfcc5c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -72,3 +72,7 @@ sync_github_name = false real_name = "Manuel Carmona" display_name = "manucarcor" [people.odedigo] + +[people.webdevtodayjason] +display_name = "Jason Brashear" +sync_github_name = false diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index 58100bf74..d9e799046 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -416,6 +416,8 @@ pub fn escape_text(s: &str) -> String { '>' => o.push_str("\\textgreater{}"), '|' => o.push_str("\\textbar{}"), '\u{A0}' => o.push('~'), + // An opening bracket here would become an optional item or line-break argument. + '[' if !line_has_text => o.push_str("{[}"), // Keep TeX from joining `--`, ``` `` ``` and `''` into dashes and quotes. '-' if prev == '-' => o.push_str("{}-"), '`' => o.push_str("\\textasciigrave{}"), @@ -3029,6 +3031,15 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after. assert!(md.contains("π r^2"), "{md}"); } + #[test] + fn leading_brackets_stay_text() { + // `\item [x]` and `\\ [1]` would read the bracket as an optional argument. + assert_eq!(escape_text("[x] a\n [1] b [c]"), "{[}x] a\\\\\n {[}1] b [c]"); + let doc = crate::markdown::import("- [x] done"); + let back = import(export(&doc).as_bytes()); + assert_eq!(back.plain_text(Default::default()), "[x] done"); + } + #[test] fn special_characters_round_trip() { let text = "50% of $5 & #1_a {b} ~c^ \\d |f| -- ``g'' tab\there"; From dcef93407859d5507487f06815d1b1a9c10ed17e Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:53:10 -0500 Subject: [PATCH 010/199] Markdown: list items that start like a block keep their text (#1100) --- crates/formats/src/markdown.rs | 8 +++++++- crates/formats/src/tests.rs | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 0df3f5a57..5a71b44f2 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -1298,7 +1298,13 @@ fn write_blocks(blocks: &[FBlock], out: &mut Vec) { let pad = " ".repeat(4 * lv); let cont = " ".repeat(4 * lv + marker.len() + 1); let body = body.replace("\\\n", &format!("\\\n{cont}")); - let body = if p.kind == Kind::Code { code_span(&p.text()) } else { body }; + let body = if p.kind == Kind::Code { + code_span(&p.text()) + } else { + let body = escape_line_start(body); + // The bullet marker can turn a short run of hyphens into a rule. + if thematic_break(&format!("{marker} {body}")) { format!("\\{body}") } else { body } + }; out.push(format!("{pad}{marker} {body}").trim_end().to_string()); prev_list = true; let next_is_list = matches!(blocks.get(i + 1), Some(FBlock::Para(q)) if q.list.is_some()); diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index d4a819002..8dea5bf98 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -194,6 +194,21 @@ fn numbered_list_after_bullets_keeps_its_kind() { assert!(md.contains("1. num one") && md.contains("2. num two"), "{md}"); } +#[test] +fn markdown_list_items_that_start_like_blocks_keep_their_text() { + for text in ["---", "--", "1. Intro", "# Agenda", "ordinary item"] { + let mut d = Document::new(); + let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet); + let mut p = Paragraph::with_text(text, CharProps::default()).styled("ListParagraph"); + p.props.numbering = Some(wordcraft_doc::props::NumRef { num: bul, level: 0 }); + d.body = vec![para_block(p)]; + let bytes = export("md", &d).unwrap().unwrap(); + let blocks = flat(&import("md", &bytes).unwrap().unwrap()); + let [FBlock::Para(p)] = blocks.as_slice() else { panic!("{text}: {}", String::from_utf8_lossy(&bytes)) }; + assert_eq!((p.text().as_str(), p.kind, p.list), (text, Kind::Normal, Some(model::ListInfo { ordered: false, level: 0 }))); + } +} + #[test] fn markdown_round_trip() { check_round_trip("md", true); From 0837c8b7e091f01ee38633beca3c1d2c34785eeb Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Wed, 14 Oct 2026 07:54:03 +0200 Subject: [PATCH 011/199] Charts of undrawn types (radar, stock...) show a placeholder (#1092) Fixes #998 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/docx/src/read/chart.rs | 86 +++++++++++++++++++++++++++++++---- docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/crates/docx/src/read/chart.rs b/crates/docx/src/read/chart.rs index 70cde2871..82ed31fbd 100644 --- a/crates/docx/src/read/chart.rs +++ b/crates/docx/src/read/chart.rs @@ -2,7 +2,8 @@ //! //! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter //! charts are drawn from the first supported chart type of the plot area, with their values as -//! data labels where `c:dLbls` shows them (`c:showVal`). Other chart types draw nothing. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate. +//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…) +//! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate. use std::f64::consts::{FRAC_PI_2, TAU}; @@ -45,20 +46,21 @@ const TEXT: Rgb = Rgb(0x59, 0x59, 0x59); const GRID: Rgb = Rgb(0xD9, 0xD9, 0xD9); const AXIS: Rgb = Rgb(0xBF, 0xBF, 0xBF); -/// Items for a chart part, sized `w` x `h` points. Empty when nothing supported is in it. +/// Items for a chart part, sized `w` x `h` points: a placeholder when only unsupported chart +/// types are in it, empty when there's nothing to draw. pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec { let mut out = Vec::new(); if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { return out; } - let Some(chart) = Chart::parse(space, theme) else { return out }; + let Some(chart) = Chart::parse(space, theme) else { + placeholder(&mut out, space, theme, w, h); + return out; + }; backdrop(&mut out, space, theme, w, h); let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) }; if let Some(t) = &chart.title { - let lh = TITLE_SIZE * LINE; - text(&mut out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true); - area.y += lh + PAD; - area.h = (area.h - lh - PAD).max(1.0); + area = title_block(&mut out, t, area); } if let Some(pos) = chart.legend { // Word lists bar series in the order they are drawn: reversed for clustered horizontal bars @@ -78,6 +80,53 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec, t: &str, area: Rect) -> Rect { + let lh = TITLE_SIZE * LINE; + text(out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true); + Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area } +} + +/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its +/// frame with a light border, its title and a line saying which type it is, so it isn't blank +/// space. Nothing when the plot area has no chart type at all, or a supported one with nothing +/// to draw. +fn placeholder(out: &mut Vec, space: &El, theme: &[Rgb], w: f32, h: f32) { + let Some(chart) = space.child("c:chart") else { return }; + let Some(plot) = chart.child("c:plotArea") else { return }; + if plot.els().any(|e| Kind::of(&e.name).is_some()) { + return; + } + let Some(ty) = plot.els().find(|e| e.name.ends_with("Chart")) else { return }; + backdrop(out, space, theme, w, h); + out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill: None, stroke: Some(AXIS), stroke_width: 0.75 }); + let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) }; + if let Some(t) = title_of(chart, &read_series(ty, false, theme)) { + area = title_block(out, &t, area); + } + let size = text_size(space); + let lh = size * LINE; + let msg = format!("{} (not supported yet)", unsupported_name(&ty.name)); + let line = Rect { x: area.x, y: area.y + ((area.h - lh) / 2.0).max(0.0), w: area.w, h: lh }; + text(out, line, &clip_text(&msg, area.w, size), size, TextAlign::Center, false); +} + +/// What a placeholder calls a chart type element WordCraft doesn't draw. +fn unsupported_name(name: &str) -> &'static str { + match name { + "c:radarChart" => "Radar chart", + "c:stockChart" => "Stock chart", + "c:bubbleChart" => "Bubble chart", + "c:surfaceChart" | "c:surface3DChart" => "Surface chart", + "c:ofPieChart" => "Pie of pie chart", + "c:bar3DChart" => "3-D bar chart", + "c:line3DChart" => "3-D line chart", + "c:area3DChart" => "3-D area chart", + "c:pie3DChart" => "3-D pie chart", + _ => "Chart", + } +} + /// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in /// white on the fill) or just beside it (above, or right of a horizontal bar's end). struct DataLabel { @@ -1792,10 +1841,27 @@ mod tests { assert!(chart_items(&El::default(), &theme(), 100.0, 100.0).is_empty()); } + fn texts(items: &[GraphicItem]) -> Vec<&str> { + items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect() + } + #[test] - fn unsupported_chart_types_draw_nothing() { - let plot = r#"1"#; - assert!(chart_items(&space(plot), &theme(), 100.0, 100.0).is_empty()); + fn unsupported_chart_types_draw_a_placeholder() { + let ser = r#"1"#; + let title = r#"Skills"#; + let x = format!(r#"{title}{ser}"#); + let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0); + assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]); + let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0])); + assert!(framed, "a light border marks the chart's frame: {radar:?}"); + + let stock = chart_items(&space(&format!("{ser}{ser}{ser}")), &theme(), 300.0, 200.0); + assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]); + let other = chart_items(&space(&format!("{ser}")), &theme(), 300.0, 200.0); + assert_eq!(texts(&other), ["Chart (not supported yet)"]); + // A supported type in the plot area is drawn, an unsupported one beside it is skipped. + let combo = chart_items(&space(&format!("{ser}{ser}")), &theme(), 300.0, 200.0); + assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}"); } /// Chart items for a chart with a legend at `pos` (`r`, `b`…). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b5c7376c7..caa73c915 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; 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-12 · **Change:** trivial (undrawn chart types show a placeholder, #998; previously trivial: saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; 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; @@ -66,7 +66,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611) | | Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | -| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | +| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | | Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. No native stroke rendering, eraser, conversion or replay; ink nested in groups/canvases remains unsupported | | 3D models | ❌ | ❌ | Dropped on read, not preserved on save | @@ -224,3 +224,4 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-10 | trivial | Custom table styles round-trip (#256 merged) | | 2026-10-10 | major | First version: Word's full format list, read/write status, DOCX element coverage from the source, fidelity and hours | | 2026-10-10 | trivial | Freeform shapes and ink as DrawingML custom geometry, read and written (#307) | +| 2026-10-12 | trivial | Charts of types WordCraft doesn't draw (radar, stock…) show a placeholder with their title and type (#998) | diff --git a/docs/gaps.md b/docs/gaps.md index d80f68cdb..5f3b62096 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-12 · **Change:** trivial (saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; 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-12 · **Change:** trivial (radar, stock and other undrawn chart types show a placeholder instead of blank space, #998; previously trivial: saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; 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. @@ -26,8 +26,8 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), 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 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~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) | +| 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) | | 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); missing: Ink to Math, arrows in Ink to Shape, 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 9/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, 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**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | @@ -147,3 +147,4 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | major | First version, from the full re-measure: 29 ranked gaps with evidence, impact and hours | | 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink landed; ink gap narrowed | | 2026-10-12 | trivial | #811: large-table scrolling fixed; performance gap (#20) narrowed | +| 2026-10-12 | trivial | #13: radar, stock and other undrawn chart types show a placeholder instead of blank space (#998) | From 927a3059433d0f2b56284066baea375fa8d08bfd Mon Sep 17 00:00:00 2001 From: naxvog Date: Wed, 14 Oct 2026 07:54:07 +0200 Subject: [PATCH 012/199] Layout: a picture after a content control's start stays on its line (regression from #392) (#1102) Since content controls are kept (#392), a picture content control puts a zero-width start marker before its picture. Line breaking keeps a line's first item even when it doesn't fit, but counted the marker as that item, so a picture a little wider than its line wrapped onto a second line, leaving a marker-only line above it; in a fixed-height text box the picture's line was then cut off. A logo in a header text box (which 0.5.0 drew) disappeared. Markers before an item no longer count, as a display equation's check already does. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/layout/src/para.rs | 4 +++- crates/layout/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 8ac9dd9e0..14c064a2c 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1728,7 +1728,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio }); let shrink = if shrink_spaces && !has_tab { space_w * MAX_SPACE_SHRINK } else { 0.0 }; let fits = absorbs || x + c.adv <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker; - if !fits && j > c0 { + // A line's first item stays on it even when it doesn't fit; markers before it + // (a content control's start, a bookmark) take no room and don't count (Word). + if !fits && (c0..j).any(|k| pl.clusters.get(k).is_some_and(|c| c.kind != ClKind::Marker)) { // Wrap: back up to the last break opportunity on this line. end = LineEnd::Wrap; // Hyphenate only when the plain break leaves more than the hyphenation zone empty. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 8b09d0e26..a46b5ceda 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3102,6 +3102,24 @@ fn inline_picture_wider_than_the_text_keeps_its_size() { assert!((r.w - 1000.0).abs() < 0.01 && (r.h - 100.0).abs() < 0.01, "{r:?}"); } +#[test] +fn a_picture_too_wide_after_a_content_control_start_stays_on_the_first_line() { + // The picture opening a picture content control (a logo in a text box a little too + // narrow for it): the control's start marker before it takes no room, so the picture is + // still the line's first item and stays there instead of leaving an empty line above it. + let mut d = Document::from_text(""); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.insert_object(0, picture(500.0, 100.0, Default::default()), &Default::default()).unwrap(); + let start = InlineObject::ControlStart { control: Box::default() }; + p.insert_object(0, start, &Default::default()).unwrap(); + } + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + assert_eq!(para.lines.len(), 1, "{:?}", para.lines); + let line = ¶.lines[0]; + assert!((line.baseline - line.top - 100.0).abs() < 0.01, "the picture is on the first line: {line:?}"); +} + /// The paragraph at `path` on page 0: its first line's x, its y and its line count. fn para_at(l: &DocLayout, path: &[u32]) -> Vec<(f32, f32, usize)> { l.pages[0] From 65b66c6216f882437817d233a7c4e1d7e908a1fa Mon Sep 17 00:00:00 2001 From: przemek-arch Date: Wed, 14 Oct 2026 07:54:13 +0200 Subject: [PATCH 013/199] Export: page images draw headers and footers at full strength (#1106) file.exportPng (and `wordcraft-cli render`) rendered with the on-screen display options, which dim headers and footers while the body is edited, so exported pages showed grey headers; the PDF export and Word's exports don't. Page images now render with dimming off. Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 6 +++++- crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ecf583443..0ebf1a3d9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -269,7 +269,11 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult { let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0); let l = s.export_layout_with(export_options(s, v).markup); let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?; - let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default()); + // A page image is the printed page: headers and footers at full strength. Dimming them is + // the on-screen cue while editing the body, not part of the page. + let mut opts = wordcraft_render::RenderOptions::default(); + opts.display.dim_header = false; + let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts); let png = img.to_png(); crate::io::write_file(path, &png).map_err(CmdError::Failed)?; Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()})) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7cc61c8e6..d69108b12 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5108,3 +5108,25 @@ fn insert_picture_takes_a_windows_metafile() { assert!(err.to_string().contains("WMF, EMF"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn exported_page_images_draw_headers_at_full_strength() { + // file.exportPng (and `wordcraft-cli render`) drew headers and footers dimmed, as the screen + // does while the body is edited; Word's exported pages and WordCraft's PDF don't. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Body"})); + let mut hp = wordcraft_doc::Paragraph::with_text("HEADER", wordcraft_doc::props::CharProps { size: Some(28.0), ..Default::default() }); + hp.props.align = Some(wordcraft_doc::props::Align::Left); + let id = s.doc.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); + s.doc.last_section.headers.default = Some(id); + let dir = std::env::temp_dir().join(format!("wordcraft-png-header-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("p.png"); + s.run("file.exportPng", &json!({"path": path.to_string_lossy(), "scale": 1.0})).unwrap(); + let img = image::open(&path).unwrap().to_luma8(); + // The darkest pixel in the top tenth of the page (the header) is black, not grey. + let top = img.height() / 10; + let darkest = (0..top).flat_map(|y| (0..img.width()).map(move |x| (x, y))).map(|(x, y)| img.get_pixel(x, y)[0]).min().unwrap(); + assert!(darkest < 60, "header drawn dimmed: darkest pixel {darkest}"); + let _ = std::fs::remove_dir_all(&dir); +} From d74ba89483be4307c263b8a471f826fafd89a427 Mon Sep 17 00:00:00 2001 From: przemek-arch Date: Wed, 14 Oct 2026 07:54:20 +0200 Subject: [PATCH 014/199] Canvas: handle keys before laying out, so a keystroke shows in its own frame (#1107) canvas::show took the layout, painted the pages and only then handled the frame's key events, so typed text changed the document after the frame was painted and appeared one frame later (about 17 ms at 60 Hz). Keys are now handled first, with the canvas focus from the last frame, and the same frame lays out and paints the change. Measured headless with the perf_probe example on a 106-page document: the work moves into the keystroke's frame (median 0.4 ms -> 16.6 ms there, same total), removing one frame of latency. Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cef25186c..44a32ef9d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -717,6 +717,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.read_first_visible = None; app.canvas.read_target_page = None; } + // Keys first, so this frame lays out and paints what was typed (handled after painting, a + // keystroke showed one frame later). Focus is the canvas's from the last frame. + // While "Save changes?" is up, keys answer it rather than edit the document behind it. + if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) { + crate::keys::canvas_events(app, ui.ctx()); + } crate::ink::replay_tick(app, ui.ctx()); let layout = app.session.layout(); if let Some(anchor) = reflow_anchor @@ -1333,10 +1339,6 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::table_resize::cursor(app, ui, &resp, &layout, &rects, geo.scale); crate::table_mouse::cursor(app, ui, &resp, &layout, &rects, geo.scale); crate::table_pen::cursor(app, ui, &resp); - // While "Save changes?" is up, keys answer it rather than edit the document behind it. - if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) { - crate::keys::canvas_events(app, ui.ctx()); - } let _ = origin; if let (Some(h), Some(v)) = (hruler, vruler) { rulers(app, ui, h, v, &rects, &layout, geo.scale); @@ -2919,6 +2921,33 @@ mod tests { assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); } + /// A typed character is laid out and painted in the frame that delivers the key, not the next + /// one: keys are handled before the canvas takes the layout it paints. + #[test] + fn a_keystroke_is_painted_in_its_own_frame() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Some text"})).unwrap(); + a.canvas.want_focus = true; + 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(); + } + assert!(h.state().canvas.focused, "the canvas has the keyboard focus"); + h.event(egui::Event::Text("x".into())); + h.step(); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "Some textx"); + let latest = h.state_mut().session.layout(); + let painted = h.state().canvas.page_keys.layout.clone(); + assert!(painted.is_some_and(|l| std::sync::Arc::ptr_eq(&l, &latest)), "the frame painted the layout with the typed character"); + } + /// #167: a comment balloon edits in place like Word's: click it, type, leave it, and the /// comment has the new text as one undo step. #[test] From f39f4b24555936ab3c9e34c78d45811caa8699e0 Mon Sep 17 00:00:00 2001 From: przemek-arch Date: Wed, 14 Oct 2026 07:54:27 +0200 Subject: [PATCH 015/199] Tools: ui.wheel control method and a headless perf_probe example (#1108) ui.wheel injects a mouse-wheel / trackpad scroll at a window point (the control channel had no scroll input). perf_probe runs the real UI headless, opens a document and times the frames that handle typing, scrolling, zooming, ribbon tabs and resizes (numbers only). Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/examples/perf_probe.rs | 149 ++++++++++++++++++++++++++ crates/ui-egui/src/control.rs | 20 ++++ crates/ui-egui/src/lib.rs | 38 +++++++ docs/control-protocol.md | 1 + 4 files changed, 208 insertions(+) create mode 100644 crates/ui-egui/examples/perf_probe.rs diff --git a/crates/ui-egui/examples/perf_probe.rs b/crates/ui-egui/examples/perf_probe.rs new file mode 100644 index 000000000..b2ef7fc42 --- /dev/null +++ b/crates/ui-egui/examples/perf_probe.rs @@ -0,0 +1,149 @@ +//! Responsiveness probe: runs the real WordCraft UI headless (like `ui_shot`, without a window or +//! focus), opens a document, and times the frames that handle typing, scrolling, zooming, +//! ribbon tabs and window resizes. Prints numbers only (JSON), never document text. +//! +//! `cargo run --release -p wordcraft-ui-egui --example perf_probe -- doc.docx [block]` +//! +//! A frame's time is the CPU time of `WordApp::logic` + `ui` for that frame (layout, page +//! rasterisation, widgets). What the user sees adds the wait for the next frame and the +//! presentation on screen (up to one display refresh). + +use std::time::Instant; + +use serde_json::json; +use wordcraft_engine::Session; +use wordcraft_ui_egui::{Services, WordApp}; + +static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 { + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().raw_input_hook(&mut raw); + *h.input_mut() = raw; + let t = Instant::now(); + h.step(); + t.elapsed().as_secs_f64() * 1000.0 +} + +fn stats(v: &[f64]) -> serde_json::Value { + let mut s = v.to_vec(); + s.sort_by(|a, b| a.total_cmp(b)); + let at = |q: f64| s.get(((s.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0); + json!({"n": s.len(), "med": (at(0.5) * 10.0).round() / 10.0, "p95": (at(0.95) * 10.0).round() / 10.0, "max": (at(1.0) * 10.0).round() / 10.0}) +} + +fn main() { + let path = std::env::args().nth(1).expect("usage: perf_probe doc.docx [block]"); + let block: Option = std::env::args().nth(2).and_then(|b| b.parse().ok()); + let t_open = Instant::now(); + let mut session = Session::new(wordcraft_doc::Document::new()); + session.run("file.open", &json!({"path": path})).expect("open"); + let open_ms = t_open.elapsed().as_secs_f64() * 1000.0; + let app = WordApp::new(session, Services::default()); + let mut h = + egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_pixels_per_point(2.0).with_max_steps(1_000_000).build_ui_state( + |ui, app: &mut WordApp| { + if !READY.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + READY.store(true, std::sync::atomic::Ordering::Relaxed); + let first: Vec = (0..6).map(|_| step(&mut h)).collect(); + let mut out = + json!({"open_ms": (open_ms * 10.0).round() / 10.0, "first_frames_ms": first.iter().map(|x| (x * 10.0).round() / 10.0).collect::>()}); + + // Typing: the caret in the middle of `block` (default: the longest paragraph), 40 characters. + let doc_block = block.unwrap_or_else(|| { + let s = &h.state().session; + s.doc.body.iter().enumerate().filter_map(|(i, b)| b.as_para().map(|p| (i, p.len()))).max_by_key(|(_, l)| *l).map(|(i, _)| i).unwrap_or(0) + }); + let typing = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: serde_json::Value| -> serde_json::Value { + h.state_mut().session.run("caret.set", &json!({"pos": pos})).ok(); + h.state_mut().canvas.want_focus = true; + for _ in 0..3 { + step(h); + } + let mut frames = Vec::new(); + let (mut lay, mut ren) = (Vec::new(), Vec::new()); + for _ in 0..40 { + h.input_mut().events.push(egui::Event::Text("a".into())); + frames.push(step(h)); + // the frame after (relayout of a long paragraph may finish there) + frames.push(step(h)); + lay.push(h.state_mut().session.layout().ms); + ren.push(h.state().canvas.render_ms); + } + let typed = frames.chunks(2).map(|c| c[0] + c[1]).collect::>(); + json!({"frame_with_key_ms": stats(&frames.iter().step_by(2).copied().collect::>()), "two_frames_ms": stats(&typed), "layout_ms": stats(&lay), "render_ms": stats(&ren)}) + }; + let mid = h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.len() / 2).unwrap_or(0); + // a char boundary at or before the middle + let mid = { + let p = h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.plain_text()).unwrap_or_default(); + (0..=mid.min(p.len())).rev().find(|i| p.is_char_boundary(*i)).unwrap_or(0) + }; + out["typing_long_para"] = typing(&mut h, json!({"story": "body", "path": [doc_block], "off": mid})); + out["typing_para_len"] = json!(h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.len()).unwrap_or(0)); + let table = h.state().session.doc.body.iter().position(|b| matches!(&**b, wordcraft_doc::Block::Table(_))); + if let Some(t) = table { + out["typing_table"] = typing(&mut h, json!({"story": "body", "path": [t, 0, 0, 0], "off": 0})); + } + + // Scrolling: mouse wheel (120 pt notches) and trackpad (20 pt gestures) over the canvas. + let scroll = |h: &mut egui_kittest::Harness<'_, WordApp>, dy: f32, n: usize| -> serde_json::Value { + h.state_mut().session.run("caret.docStart", &json!({})).ok(); + for _ in 0..3 { + step(h); + } + let mut f = Vec::new(); + for _ in 0..n { + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(720.0, 500.0))); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + f.push(step(h)); + } + json!({"frame_ms": stats(&f), "frames_over_33ms": f.iter().filter(|x| **x > 33.0).count(), "frames_over_16ms": f.iter().filter(|x| **x > 16.7).count()}) + }; + out["scroll_wheel"] = scroll(&mut h, -120.0, 120); + out["scroll_trackpad"] = scroll(&mut h, -20.0, 300); + + // Zoom, ribbon tabs, resize: the frame after the change. + let mut zoom = Vec::new(); + for z in [50, 75, 100, 150, 200, 100] { + h.state_mut().session.run("view.zoom", &json!({"value": z})).ok(); + let a = step(&mut h); + let b = step(&mut h); + zoom.push(json!({"zoom": z, "frame_ms": (a * 10.0).round() / 10.0, "next_ms": (b * 10.0).round() / 10.0})); + } + out["zoom"] = json!(zoom); + let mut tabs = Vec::new(); + for t in ["Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Home"] { + h.state_mut().execute("ui.tab", json!({"tab": t})).ok(); + tabs.push(step(&mut h)); + } + out["ribbon_tab_ms"] = stats(&tabs); + let mut rs = Vec::new(); + for w in (800..=1440).rev().step_by(40).chain((800..=1440).step_by(40)) { + h.set_size(egui::vec2(w as f32, 900.0)); + rs.push(step(&mut h)); + } + out["resize_ms"] = stats(&rs); + // Accept all (if the document has tracked changes). + if !h.state().session.doc.revisions.is_empty() { + let t = Instant::now(); + h.state_mut().session.run("review.acceptAll", &json!({})).ok(); + let cmd = t.elapsed().as_secs_f64() * 1000.0; + let f = step(&mut h); + out["accept_all"] = json!({"command_ms": (cmd * 10.0).round() / 10.0, "frame_ms": (f * 10.0).round() / 10.0}); + } + println!("{out}"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dda9412f4..dd9b5992b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -4,6 +4,7 @@ //! - `engine.execute {command, params}`: run any command (engine or `ui.*`) //! - `engine.commands {search?}`: engine and app print commands with enablement and parameters //! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf +//! - `ui.wheel {x, y, dx?, dy, unit?, phase?}`: a mouse-wheel / trackpad scroll at a window point. //! - `ui.click {x, y, button?, count?, shift?, cmd?}`, `ui.move {x, y}`, `ui.drag {x, y, toX, toY}`: //! real pointer input in screen points (reaches every widget) //! - `ui.clickText {page, x, y}`: click at a page position (points from the page's top-left) @@ -204,6 +205,25 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); ok(json!({"queued": true})) } + "ui.wheel" => { + // Mouse-wheel or trackpad scroll at (x, y): `dy` points (positive scrolls up), or + // lines with `"unit": "line"`; `phase` "start"/"move"/"end" for a trackpad gesture. + let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; + let unit = match s("unit") { + Some("line") => egui::MouseWheelUnit::Line, + Some("page") => egui::MouseWheelUnit::Page, + _ => egui::MouseWheelUnit::Point, + }; + let phase = match s("phase") { + Some("start") => egui::TouchPhase::Start, + Some("end") => egui::TouchPhase::End, + _ => egui::TouchPhase::Move, + }; + let delta = egui::vec2(f("dx").unwrap_or(0.0), f("dy").unwrap_or(0.0)); + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y))); + app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p) }); + ok(json!({"queued": true})) + } "ui.text" => { let Some(t) = s("text") else { return err("missing text") }; app.synthetic.push(egui::Event::Text(t.to_string())); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4afbd939..98084665d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2540,6 +2540,44 @@ mod tests { assert!(!a.session.sel.is_collapsed(), "the drag left a selection"); } + /// The control channel's `ui.wheel` scrolls the canvas like a mouse wheel over it. + #[test] + fn control_wheel_scrolls_the_canvas() { + let ctx = egui::Context::default(); + let mut a = app(); + for _ in 0..3 { + a.run("insert.pageBreak", json!({})).unwrap(); + } + a.run("caret.docStart", json!({})).unwrap(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp| { + t += 1.0 / 60.0; + let mut input = egui::RawInput { time: Some(t), screen_rect: Some(screen), ..Default::default() }; + a.raw_input_hook(&mut input); + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + for _ in 0..3 { + frame(&mut a); + } + let top = a.canvas.page_rects[0].top(); + let at = a.canvas.canvas_rect.unwrap().center(); + let (req, _reply) = ControlRequest::new("ui.wheel", json!({"x": at.x, "y": at.y, "dy": -400.0})); + control::handle(&mut a, &ctx, &req); + for _ in 0..30 { + frame(&mut a); + } + let moved = top - a.canvas.page_rects[0].top(); + assert!(moved > 100.0, "the first page moved up by {moved}"); + let (req, _reply) = ControlRequest::new("ui.wheel", json!({"x": at.x})); + let control::Outcome::Done(v) = control::handle(&mut a, &ctx, &req) else { panic!("ui.wheel replies at once") }; + assert_eq!(v["ok"], false, "y is required: {v}"); + } + /// Issue #123: zoomed out, pages sit side by side, and clicks map to the page under the pointer. #[test] fn zoomed_out_pages_sit_side_by_side_and_clicks_land_on_them() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4e71ecb54..70c84d5c7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -59,6 +59,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page | | `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag | | `ui.press` / `ui.release` | `x`,`y`, modifiers | primary button down / up there (with `ui.move` between: a drag you can screenshot halfway) | +| `ui.wheel` | `x`, `y`, `dy`, `dx?`, `unit?` (`point`/`line`/`page`), `phase?` (`start`/`move`/`end`), modifiers | mouse-wheel or trackpad scroll at window coordinates (`dy` < 0 scrolls down) | | `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) | | `ui.text` | `text` | typed text through egui | | `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply | From 1e48e6710f0f7822839dfe93d035b1bdeb9a5917 Mon Sep 17 00:00:00 2001 From: przemek-arch Date: Wed, 14 Oct 2026 07:54:33 +0200 Subject: [PATCH 016/199] Tools: cargo xtask visual-diff compares two PDFs of a document page by page (#1112) SSIM of the greyscale pages (8x8 windows), share of differing pixels, the share of reference lines with the same line break, median |dy| / max |dx| of matched lines and page-start agreement, as result.json, plus diff maps (reference red, WordCraft blue). --private writes numbers and line hashes only. Std + serde_json like the rest of xtask; poppler's pdftoppm / pdftotext do the rasterising and text extraction. Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- xtask/src/main.rs | 5 + xtask/src/visual_diff.rs | 381 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 xtask/src/visual_diff.rs diff --git a/xtask/src/main.rs b/xtask/src/main.rs index be6525536..3568ae903 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,6 +9,7 @@ mod layers; mod parity; mod stats; mod version; +mod visual_diff; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -25,6 +26,9 @@ commands: show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) parity recompute the Word feature-parity summary in docs/parity-checklist.md + visual-diff [--private] [--dpi N] + compare two PDFs of a document page by page (SSIM, line breaks, offsets); + needs poppler (pdftoppm, pdftotext) ico ... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) "; @@ -41,6 +45,7 @@ fn main() -> ExitCode { Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("parity") => parity::run(&root()), Some("ico") => ico::run(&rest), + Some("visual-diff") => visual_diff::run(&rest), Some("version") => version::run(&root(), &rest), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); diff --git a/xtask/src/visual_diff.rs b/xtask/src/visual_diff.rs new file mode 100644 index 000000000..20f857c98 --- /dev/null +++ b/xtask/src/visual_diff.rs @@ -0,0 +1,381 @@ +//! `cargo xtask visual-diff REF.pdf TEST.pdf OUTDIR [--private] [--dpi N]`: page-by-page +//! comparison of two PDFs of the same document — a reference the user exported themselves (never +//! committed: files produced by other word processors stay out of the repo) and WordCraft's export. +//! +//! Per page: SSIM of the greyscale raster (8×8 windows), share of pixels differing by more than 64 +//! levels, lines in each, share of the reference's lines found with the same text in WordCraft +//! (same line break), median |Δy| and max |Δx| of the matched lines, whether the page starts with +//! the same line. Writes `OUTDIR/result.json` and, unless `--private`, `diff_pNNN.ppm` maps +//! (reference red, WordCraft blue, shared ink black). `--private` writes numbers and line hashes +//! only, for confidential documents. +//! +//! Needs poppler's `pdftoppm` and `pdftotext` on PATH. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{Value, json}; + +struct Gray { + w: usize, + h: usize, + px: Vec, +} + +/// One text line: page (0-based), top y, left x, text. +struct Line { + page: usize, + y: f64, + x: f64, + text: String, +} + +pub fn run(args: &[&str]) -> Result<(), String> { + let private = args.contains(&"--private"); + let dpi = match args.iter().position(|a| *a == "--dpi") { + Some(i) => args.get(i + 1).and_then(|v| v.parse::().ok()).filter(|d| (36..=300).contains(d)).ok_or("--dpi takes 36..=300")?, + None => 100, + }; + let pos: Vec<&str> = + args.iter().enumerate().filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args[i - 1] == "--dpi")).map(|(_, a)| *a).collect(); + let [reference, test, out] = pos.as_slice() else { + return Err("usage: cargo xtask visual-diff REF.pdf TEST.pdf OUTDIR [--private] [--dpi N]".into()); + }; + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?; + let tmp = out.join(".visual-diff-tmp"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?; + let result = compare(Path::new(reference), Path::new(test), &out, &tmp, dpi, private); + let _ = std::fs::remove_dir_all(&tmp); + let report = result?; + std::fs::write(out.join("result.json"), serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + println!("{}", report["summary"]); + Ok(()) +} + +fn compare(reference: &Path, test: &Path, out: &Path, tmp: &Path, dpi: u32, private: bool) -> Result { + let rp = rasterize(reference, dpi, tmp, "r")?; + let tp = rasterize(test, dpi, tmp, "t")?; + let l1 = lines_of(reference)?; + let l2 = lines_of(test)?; + let t1: Vec = l1.iter().map(|l| norm(&l.text)).collect(); + let t2: Vec = l2.iter().map(|l| norm(&l.text)).collect(); + let pairs = lcs(&t1, &t2); + let mut matched = vec![None; l1.len()]; + for &(a, b) in &pairs { + matched[a] = Some(b); + } + let label = |t: &str| if private { hash(t) } else { t.chars().take(70).collect() }; + let mut pages = Vec::new(); + let mut dx_diffs = Vec::new(); + let mut ssims = Vec::new(); + for p in 0..rp.len().max(tp.len()) { + let mut row = json!({"page": p + 1}); + if let (Some(a), Some(b)) = (rp.get(p), tp.get(p)) { + let (ga, gb) = (read_pgm(a)?, read_pgm(b)?); + let s = ssim(&ga, &gb); + ssims.push(s); + row["ssim"] = json!(round(s, 4)); + row["diff_px"] = json!(round(diff_share(&ga, &gb) * 100.0, 2)); + if !private { + write_diff_map(&ga, &gb, &out.join(format!("diff_p{:03}.ppm", p + 1)))?; + } + } + let ref_ix: Vec = (0..l1.len()).filter(|&i| l1[i].page == p).collect(); + row["lines"] = json!([ref_ix.len(), l2.iter().filter(|l| l.page == p).count()]); + let same: Vec<(usize, usize)> = ref_ix.iter().filter_map(|&i| matched[i].map(|j| (i, j))).collect(); + if !ref_ix.is_empty() { + row["same_break"] = json!(round(100.0 * same.len() as f64 / ref_ix.len() as f64, 1)); + } + let mut dys: Vec = same.iter().filter(|&&(_, j)| l2[j].page == p).map(|&(i, j)| (l1[i].y - l2[j].y).abs()).collect(); + dys.sort_by(f64::total_cmp); + if let Some(m) = dys.get(dys.len() / 2) { + row["dy"] = json!(round(*m, 1)); + } + let mut max_dx = 0.0f64; + for &(i, j) in &same { + let dx = l2[j].x - l1[i].x; + max_dx = max_dx.max(dx.abs()); + if dx.abs() > 6.0 { + dx_diffs.push(json!({"page": p + 1, "kind": "dx", "value": round(dx, 1), "line": label(&t1[i])})); + } + } + row["dx"] = json!(round(max_dx, 1)); + if let Some(&first) = ref_ix.first() { + let starts = matched[first].is_some_and(|j| l2[j].page == p && (j == 0 || l2[j - 1].page != p)); + row["page_start_same"] = json!(starts); + } + pages.push(row); + } + // Runs of lines that differ between matched lines: different line breaks (first 40). + let mut breaks = Vec::new(); + let (mut i0, mut j0) = (0usize, 0usize); + for &(a, b) in pairs.iter().chain(std::iter::once(&(l1.len(), l2.len()))) { + if (a > i0 || b > j0) && breaks.len() < 40 { + breaks.push(json!({ + "ref_page": l1.get(i0).map(|l| l.page + 1), + "ref_lines": a - i0, + "test_lines": b - j0, + "ref": t1[i0..a].iter().take(3).map(|t| label(t)).collect::>(), + "test": t2[j0..b].iter().take(3).map(|t| label(t)).collect::>(), + })); + } + (i0, j0) = (a + 1, b + 1); + } + dx_diffs.sort_by(|a, b| b["value"].as_f64().unwrap_or(0.0).abs().total_cmp(&a["value"].as_f64().unwrap_or(0.0).abs())); + dx_diffs.truncate(50); + let summary = json!({ + "pages": [rp.len(), tp.len()], + "ssim_mean": round(if ssims.is_empty() { 0.0 } else { ssims.iter().sum::() / ssims.len() as f64 }, 4), + "same_break_pct": round(100.0 * pairs.len() as f64 / l1.len().max(1) as f64, 1), + "page_starts_same": pages.iter().filter(|r| r["page_start_same"] == true).count(), + "lines": [l1.len(), l2.len()], + }); + Ok(json!({"summary": summary, "pages": pages, "dx_diffs": dx_diffs, "break_diffs": breaks})) +} + +fn tool(name: &str, args: &[&std::ffi::OsStr]) -> Result, String> { + let o = Command::new(name).args(args).output().map_err(|e| format!("{name} (poppler) not found: {e}"))?; + if !o.status.success() { + return Err(format!("{name} failed: {}", String::from_utf8_lossy(&o.stderr).trim())); + } + Ok(o.stdout) +} + +/// Page images as greyscale PGM files, in page order. +fn rasterize(pdf: &Path, dpi: u32, tmp: &Path, tag: &str) -> Result, String> { + let prefix = tmp.join(tag); + let dpi = dpi.to_string(); + tool("pdftoppm", &["-r".as_ref(), dpi.as_ref(), "-gray".as_ref(), pdf.as_os_str(), prefix.as_os_str()])?; + let mut files: Vec<(usize, PathBuf)> = std::fs::read_dir(tmp) + .map_err(|e| e.to_string())? + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + let n = name.strip_prefix(&format!("{tag}-"))?.strip_suffix(".pgm")?.parse().ok()?; + Some((n, e.path())) + }) + .collect(); + files.sort(); + Ok(files.into_iter().map(|(_, p)| p).collect()) +} + +/// A binary (P5) 8-bit PGM. +fn read_pgm(path: &Path) -> Result { + let data = std::fs::read(path).map_err(|e| e.to_string())?; + let mut fields = Vec::new(); + let mut i = 0; + while fields.len() < 4 && i < data.len() { + if data[i] == b'#' { + while i < data.len() && data[i] != b'\n' { + i += 1; + } + } else if data[i].is_ascii_whitespace() { + i += 1; + } else { + let start = i; + while i < data.len() && !data[i].is_ascii_whitespace() { + i += 1; + } + fields.push(String::from_utf8_lossy(&data[start..i]).into_owned()); + } + } + let bad = || format!("{}: not an 8-bit P5 PGM", path.display()); + let [magic, w, h, max] = fields.as_slice() else { return Err(bad()) }; + let (w, h): (usize, usize) = (w.parse().map_err(|_| bad())?, h.parse().map_err(|_| bad())?); + if magic != "P5" || max != "255" { + return Err(bad()); + } + let px = data.get(i + 1..i + 1 + w * h).ok_or_else(bad)?.to_vec(); + Ok(Gray { w, h, px }) +} + +/// Mean SSIM over non-overlapping 8×8 windows of the common area. +fn ssim(a: &Gray, b: &Gray) -> f64 { + const WIN: usize = 8; + let (w, h) = ((a.w.min(b.w) / WIN) * WIN, (a.h.min(b.h) / WIN) * WIN); + let (c1, c2) = ((0.01f64 * 255.0).powi(2), (0.03f64 * 255.0).powi(2)); + let (mut sum, mut n) = (0.0, 0usize); + for by in (0..h).step_by(WIN) { + for bx in (0..w).step_by(WIN) { + let (mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0); + for y in by..by + WIN { + for x in bx..bx + WIN { + let (p, q) = (f64::from(a.px[y * a.w + x]), f64::from(b.px[y * b.w + x])); + (sa, sb, saa, sbb, sab) = (sa + p, sb + q, saa + p * p, sbb + q * q, sab + p * q); + } + } + let k = (WIN * WIN) as f64; + let (ma, mb) = (sa / k, sb / k); + let (va, vb, cov) = (saa / k - ma * ma, sbb / k - mb * mb, sab / k - ma * mb); + sum += ((2.0 * ma * mb + c1) * (2.0 * cov + c2)) / ((ma * ma + mb * mb + c1) * (va + vb + c2)); + n += 1; + } + } + if n == 0 { 0.0 } else { sum / n as f64 } +} + +fn diff_share(a: &Gray, b: &Gray) -> f64 { + let (w, h) = (a.w.min(b.w), a.h.min(b.h)); + let mut n = 0usize; + for y in 0..h { + for x in 0..w { + if a.px[y * a.w + x].abs_diff(b.px[y * b.w + x]) > 64 { + n += 1; + } + } + } + if w * h == 0 { 0.0 } else { n as f64 / (w * h) as f64 } +} + +/// Reference ink red, WordCraft ink blue, both black (binary PPM). +fn write_diff_map(a: &Gray, b: &Gray, path: &Path) -> Result<(), String> { + let (w, h) = (a.w.min(b.w), a.h.min(b.h)); + let mut out = format!("P6\n{w} {h}\n255\n").into_bytes(); + for y in 0..h { + for x in 0..w { + let (p, q) = (a.px[y * a.w + x], b.px[y * b.w + x]); + out.extend_from_slice(&[q, p.min(q), p]); + } + } + std::fs::write(path, out).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Lines of the text layer, from `pdftotext -bbox-layout`. +fn lines_of(pdf: &Path) -> Result, String> { + let xml = String::from_utf8_lossy(&tool("pdftotext", &["-bbox-layout".as_ref(), pdf.as_os_str(), "-".as_ref()])?).into_owned(); + Ok(parse_bbox(&xml)) +} + +fn attr(tag: &str, name: &str) -> Option { + let at = tag.find(&format!(" {name}=\""))? + name.len() + 3; + tag.get(at..)?.split('"').next()?.parse().ok() +} + +fn parse_bbox(xml: &str) -> Vec { + let mut lines = Vec::new(); + for (page, body) in xml.split("").next().unwrap_or(""); + let mut words = Vec::new(); + let mut first: Option<(f64, f64)> = None; + for w in line.split("') else { continue }; + let text = rest.split("").next().unwrap_or(""); + if first.is_none() { + first = attr(tag, "xMin").zip(attr(tag, "yMin")); + } + words.push(unescape(text)); + } + if let (Some((x, y)), false) = (first, words.is_empty()) { + lines.push(Line { page, y, x, text: words.join(" ") }); + } + } + } + lines +} + +fn unescape(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} + +fn norm(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Longest common subsequence of two line lists, as matched index pairs in order. +fn lcs(a: &[String], b: &[String]) -> Vec<(usize, usize)> { + // Equal lines at the start and end first: keeps the table small for near-identical documents. + let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count(); + let suf = a[pre..].iter().rev().zip(b[pre..].iter().rev()).take_while(|(x, y)| x == y).count(); + let (ma, mb) = (&a[pre..a.len() - suf], &b[pre..b.len() - suf]); + let (n, m) = (ma.len(), mb.len()); + let mut out: Vec<(usize, usize)> = (0..pre).map(|i| (i, i)).collect(); + // The table is (n+1)(m+1) cells: past ~100 M (400 MB) keep only the equal start and end. + if (n + 1).saturating_mul(m + 1) > 100_000_000 { + out.extend((0..suf).map(|k| (a.len() - suf + k, b.len() - suf + k))); + return out; + } + let mut t = vec![0u32; (n + 1) * (m + 1)]; + for i in (0..n).rev() { + for j in (0..m).rev() { + t[i * (m + 1) + j] = if ma[i] == mb[j] { t[(i + 1) * (m + 1) + j + 1] + 1 } else { t[(i + 1) * (m + 1) + j].max(t[i * (m + 1) + j + 1]) }; + } + } + let (mut i, mut j) = (0, 0); + while i < n && j < m { + if ma[i] == mb[j] { + out.push((pre + i, pre + j)); + (i, j) = (i + 1, j + 1); + } else if t[(i + 1) * (m + 1) + j] >= t[i * (m + 1) + j + 1] { + i += 1; + } else { + j += 1; + } + } + out.extend((0..suf).map(|k| (a.len() - suf + k, b.len() - suf + k))); + out +} + +/// A short stable hash of a line, for `--private` (FNV-1a, 64 bit). +fn hash(s: &str) -> String { + let h = s.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + format!("{h:016x}")[..10].to_string() +} + +fn round(v: f64, places: i32) -> f64 { + let k = 10f64.powi(places); + (v * k).round() / k +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gray(w: usize, h: usize, f: impl Fn(usize, usize) -> u8) -> Gray { + Gray { w, h, px: (0..w * h).map(|i| f(i % w, i / w)).collect() } + } + + #[test] + fn ssim_is_one_for_the_same_image_and_lower_for_a_shifted_one() { + let a = gray(64, 64, |x, y| if (x / 4 + y / 4) % 2 == 0 { 0 } else { 255 }); + let b = gray(64, 64, |x, y| if ((x + 2) / 4 + y / 4) % 2 == 0 { 0 } else { 255 }); + assert!((ssim(&a, &a) - 1.0).abs() < 1e-9); + assert!(ssim(&a, &b) < 0.9); + assert_eq!(diff_share(&a, &a), 0.0); + } + + #[test] + fn lcs_matches_lines_in_order() { + let v = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::>(); + let a = v(&["intro", "one two", "three", "end"]); + let b = v(&["intro", "one", "two three", "end"]); + assert_eq!(lcs(&a, &b), vec![(0, 0), (3, 3)]); + assert_eq!(lcs(&a, &a).len(), 4); + } + + #[test] + fn bbox_layout_lines_keep_page_position_and_text() { + let xml = r#" +Tom& Jerry +Two"#; + let l = parse_bbox(xml); + assert_eq!(l.len(), 2); + assert_eq!((l[0].page, l[0].x, l[0].y, l[0].text.as_str()), (0, 72.0, 80.5, "Tom & Jerry")); + assert_eq!((l[1].page, l[1].text.as_str()), (1, "Two")); + } + + #[test] + fn pgm_header_with_a_comment_is_read() { + let dir = std::env::temp_dir().join(format!("wc-vd-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let p = dir.join("a.pgm"); + let mut data = b"P5\n# made by test\n2 2\n255\n".to_vec(); + data.extend_from_slice(&[0, 64, 128, 255]); + std::fs::write(&p, data).unwrap(); + let g = read_pgm(&p).unwrap(); + assert_eq!((g.w, g.h, g.px.clone()), (2, 2, vec![0, 64, 128, 255])); + let _ = std::fs::remove_dir_all(&dir); + } +} From 196f828f3f18b3be5aac8552d0e8bb1e533619e6 Mon Sep 17 00:00:00 2001 From: Dante Souza e Souza Date: Wed, 14 Oct 2026 02:55:30 -0300 Subject: [PATCH 017/199] Expand sample-text formulas on Enter (#1067) (#1101) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- contributors/people.toml | 3 ++ crates/engine/src/cmd/text.rs | 58 +++++++++++++++++++++++ crates/engine/src/tests_typing.rs | 78 +++++++++++++++++++++++++++++++ docs/gaps.md | 7 ++- docs/typing-parity.md | 19 +++++++- 6 files changed, 166 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fc8a63689..232233225 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-12 · **Change:** trivial (direct run-level native InkML content parts survive DOCX saves; previously trivial: progress log: 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-12 · **Change:** trivial (sample-text formulas on Enter, #1067; previously trivial: direct run-level native InkML content parts survive DOCX saves; previously trivial: progress log: 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. @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo | | 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet | | 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | @@ -275,6 +276,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | | 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/contributors/people.toml b/contributors/people.toml index a10cfcc5c..5a54f37ea 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.lokinmodar] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index c962cc9f1..23b79c97f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -385,12 +385,70 @@ fn ins_char(s: &mut Session, c: char) -> CmdResult { sel_result(s) } +/// Exact sample-text formulas; bounded counts keep typed input from allocating without limit. +fn sample_text(s: &Session) -> Option { + let at = &s.sel.focus; + let para = s.doc.para_at(at)?; + if !s.sel.is_collapsed() || at.off != para.len() { + return None; + } + let (args, sentences) = if let Some(args) = para.text.strip_prefix("=rand(") { + ( + args, + [ + "A blank page leaves room for a new idea.", + "The morning light falls across a notebook on the desk.", + "Small changes in spacing can give a story a different rhythm.", + ], + ) + } else { + ( + para.text.strip_prefix("=lorem(")?, + [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + ], + ) + }; + let args = args.strip_suffix(')')?; + if args.contains(['(', ')']) { + return None; + } + let count = |arg: &str| arg.parse::().ok().filter(|n| (1..=200).contains(n)); + let counts = if args.is_empty() { + Some((5, 3)) + } else if let Some((p, s)) = args.split_once(',') { + count(p).zip(count(s)) + } else { + count(args).map(|p| (p, 3)) + }; + let (paragraphs, lines) = counts.unwrap_or((5, 3)); + let mut text = String::new(); + for _ in 0..paragraphs { + for sentence in sentences.iter().cycle().take(lines) { + if !text.is_empty() && !text.ends_with('\r') { + text.push(' '); + } + text.push_str(sentence); + } + text.push('\r'); + } + Some(text) +} + fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { // Enter in a one-line plain text control (or a check box or list) does nothing, as in Word. if super::controls::refuses_paragraph(s) { return sel_result(s); } super::controls::prepare_edit(s)?; + if let Some(text) = sample_text(s) { + let end = s.sel.focus.clone(); + s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end }; + type_text(s, &text)?; + return sel_result(s); + } let mut at = delete_selection(s)?; let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table)); if let Some(auto) = auto { diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 6b2c97be8..70e75be47 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -49,6 +49,84 @@ fn typed(keys: &str) -> Session { s } +#[test] +fn sample_text_formulas_expand_on_enter() { + for name in ["rand", "lorem"] { + for (args, paragraphs, sentences) in [("", 5, 3), ("2", 2, 3), ("2,4", 2, 4), ("1,1", 1, 1), ("200,1", 200, 1), ("1,200", 1, 200)] { + let mut s = typed(&format!("={name}({args})")); + let depth = s.undo_depth(); + typ(&mut s, "\n"); + let generated: Vec<_> = paras(&s).into_iter().map(|p| p.0).collect(); + assert_eq!(generated.len(), paragraphs + 1, "{name}({args})"); + for text in generated.iter().take(paragraphs) { + assert_eq!(text.matches('.').count(), sentences); + assert!(text.starts_with(if name == "lorem" { "Lorem ipsum" } else { "A blank page" }), "{name}({args}): {text:?}"); + } + assert_eq!(generated.last().unwrap(), ""); + assert_eq!(s.sel.focus, Pos::body(paragraphs, 0)); + assert_eq!(s.undo_depth(), depth + 1); + let expanded = s.doc.clone(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), [format!("={name}({args})")]); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, expanded); + } + } +} + +#[test] +fn sample_text_invalid_parameters_use_defaults() { + for name in ["rand", "lorem"] { + for args in ["0", "-1", "201", "abc", "1.5", "99999999999999999999999999999", "2,0", "0,4", "2,201", "2,no", "2,", ",4", "1,2,3"] { + let s = typed(&format!("={name}({args})\n")); + let texts = texts(&s); + assert_eq!(texts.len(), 6, "{name}({args})"); + assert!(texts.iter().take(5).all(|text| text.matches('.').count() == 3)); + } + } +} + +#[test] +fn sample_text_requires_an_exact_paragraph_and_caret_at_end() { + for formula in [" =rand()", "=rand() ", "prefix =rand()", "=lorem() suffix", "=other()", "=rand(", "=rand(())"] { + assert_eq!(texts(&typed(&format!("{formula}\n"))), [formula, ""]); + } + let mut s = typed("=rand()"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["", "=rand()"]); + let mut s = typed("=rand()suffix"); + s.sel = crate::Selection { anchor: Pos::body(0, 7), focus: Pos::body(0, 13) }; + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["=rand()", ""]); +} + +#[test] +fn sample_text_preserves_typing_and_paragraph_formatting() { + let mut s = s(); + run(&mut s, "format.bold", json!({})); + run(&mut s, "para.alignCenter", json!({})); + typ(&mut s, "=rand(2,1)\n"); + for p in s.doc.body.iter().filter_map(|b| b.as_para()).take(2) { + assert_eq!(p.props.align, Some(wordcraft_doc::props::Align::Center)); + assert_eq!(p.props_at(0).bold, Some(true)); + } +} + +#[test] +fn sample_text_replacement_tracks_changes() { + let mut s = typed("=lorem(2,1)"); + run(&mut s, "review.trackChanges", json!({})); + typ(&mut s, "\n"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).any(|r| r.props.ins.is_some())); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["=lorem(2,1)"]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(texts(&s).len(), 3); + assert!(!s.doc.plain_text(StoryRef::Body).contains("=lorem")); +} + #[test] fn enter_twice_after_a_list_item_ends_the_list() { let s = typed("* item\n\ntext"); diff --git a/docs/gaps.md b/docs/gaps.md index 5f3b62096..90eed0751 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-12 · **Change:** trivial (radar, stock and other undrawn chart types show a placeholder instead of blank space, #998; previously trivial: saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; 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-12 · **Change:** trivial (sample-text formulas on Enter, #1067; previously trivial: radar, stock and other undrawn chart types show a placeholder instead of blank space, #998; previously trivial: saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; 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. @@ -51,6 +51,10 @@ full number from ~60% to ~75%. Everything else is depth on the way to full parit ## By kind +Sample-text formulas on Enter (#1067) are implemented: `=rand()` and `=lorem()`, +optional paragraph/sentence counts and one-step undo. See [typing-parity.md](typing-parity.md) +for the accepted parameter range and validation limits; readiness numbers are unchanged. + The same gaps grouped the way the parity documents are, for agents working in one area. - **Feature gaps:** #2 (preserve), #5, #10, #12, #13, #15, #17, #21, #25, #27, #28, #29; Clipboard @@ -73,6 +77,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word | | 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | | 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | | 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 07b3e77bf..c4be6aebe 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-12 · **Change:** 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-12 · **Change:** trivial (sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -8,6 +8,22 @@ WordCraft, results read back paragraph by paragraph) and pinned by tests in tests together. Line breaking and pagination are in [`layout-parity.md`](layout-parity.md); the overall picture is in [`target-app-parity.md`](target-app-parity.md). +## Sample text (#1067) + +Enter at the end of a paragraph containing exactly `=rand()` or `=lorem()` replaces +the formula with five paragraphs of three sentences and leaves the caret in a following +empty paragraph. `=rand(p)` / `=lorem(p)` set the paragraph count; `(p,s)` sets both +counts. WordCraft accepts integers from 1 to 200 for each count; if either parameter +is invalid or outside that range, both revert to 5/3. Other text, a selection or Enter +inside the formula follows ordinary Enter behaviour. The English text is original to +WordCraft; lorem ipsum uses traditional pseudo-Latin. This implements the issue's +requested defaults; the exact numerical limits have not been compared in Word. + +The replacement and its paragraph breaks are one undo step. Typing and paragraph +formatting use the existing editing primitives. Tests: `sample_text_formulas_expand_on_enter`, +`sample_text_invalid_parameters_use_defaults`, `sample_text_requires_an_exact_paragraph_and_caret_at_end`, +`sample_text_preserves_typing_and_paragraph_formatting`, `sample_text_replacement_tracks_changes`. + ## Lists | Keys | Word's result | Test | @@ -168,6 +184,7 @@ Word. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Sample-text formulas on Enter (#1067), exact matching, bounded parameters, original English and one-step undo | | 2026-10-12 | 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) | | 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) | | 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) | From 15127fb53d183a8efda431e4d9a933d2be27dab5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:55:35 +0200 Subject: [PATCH 018/199] Backstage: add Close to the side menu, after Export (#1125) --- crates/ui-egui/src/backstage.rs | 64 +++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index bfce74d14..6cc225275 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -1,4 +1,4 @@ -//! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Options, About. +//! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Close, Options, About. use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; @@ -6,7 +6,7 @@ use serde_json::json; use crate::theme::{APP_COLOR, Tokens, medium, regular, semibold}; use crate::{WordApp, icons}; -const PAGES: [(&str, &str); 9] = [ +const PAGES: [(&str, &str); 10] = [ ("home", "Home"), ("new", "New"), ("open", "Open"), @@ -15,6 +15,7 @@ const PAGES: [(&str, &str); 9] = [ ("saveAs", "Save As"), ("print", "Print"), ("export", "Export"), + ("close", "Close"), ("options", "Options"), ]; @@ -93,6 +94,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { "saveAs" => { app.ui.backstage_page = "saveAs".into(); } + // Not a page: `file.close` asks Save / Don't Save / Cancel first when there + // are unsaved changes, and Cancel leaves the Backstage where it was. + "close" => { + let _ = app.run("file.close", json!({})); + } _ => app.ui.backstage_page = id.into(), } } @@ -587,6 +593,60 @@ mod tests { assert!(!h.state().ui.backstage, "successful save closes backstage"); } + /// #1124: the side menu had no Close. It sits after Export and closes the document, asking + /// first when there are unsaved changes; Cancel leaves the Backstage as it was. + #[test] + fn backstage_close_asks_then_closes() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("text.insert", json!({"text": "Draft"})).unwrap(); + a.run("ui.backstage", json!({"value": true, "page": "info"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + let export = h.get_by_label("Export").rect().min.y; + assert!(h.get_by_label("Close").rect().min.y > export, "Close comes after Export"); + h.get_by_label("Close").click(); + h.run_steps(4); + assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "unsaved changes ask first"); + assert!(!h.state().quit_requested); + + h.state_mut().run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + h.run_steps(4); + assert!(!h.state().quit_requested, "Cancel keeps the document open"); + assert!(h.state().ui.backstage && h.state().ui.backstage_page == "info", "Cancel leaves the Backstage as it was"); + + h.get_by_label("Close").click(); + h.run_steps(4); + h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(h.state().quit_requested, "Don't Save closes"); + } + + /// A document without unsaved changes closes without a prompt. + #[test] + fn backstage_close_on_a_clean_document_closes_at_once() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Close").click(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "no prompt"); + assert!(h.state().quit_requested); + } + #[test] fn backstage_save_as_sidebar_opens_save_as_page() { let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); From cd5b1a5077f863130ffece095b504ae515f250b0 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 07:55:41 +0200 Subject: [PATCH 019/199] Regression test: DrawingML shapes survive saving a .docx (#886) (#1130) Reads rectangles, ellipses, triangles, arrows, stars, a connector with an arrowhead and a text box, saves and reads back: kind, size, fill, outline and width, position, wrapping, arrowhead and text-box text all match, and the saved XML holds a wps:wsp per shape with its preset. The data loss in #886 was fixed on the reading side by #671; this guards it. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/docx/tests/fixtures.rs | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index c4e5aee99..350a007b0 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1609,6 +1609,88 @@ fn cover_page_group_fills_freeforms_and_text_box_margins() { check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap()); } +/// #886: saving dropped Word's DrawingML shapes (rectangles, ellipses, triangles, arrows, +/// stars, connectors and their theme fills). Each kind the report lists is read, saved and read +/// back with the same kind, fill, outline, size and position, and the saved part still holds a +/// `wps:wsp` with its preset geometry. +#[test] +fn drawingml_shapes_survive_saving() { + let style = r#""#; + let geom = |prst: &str| format!(r#""#); + let presets = ["rect", "ellipse", "triangle", "rightArrow", "star5"]; + let mut body: String = presets.iter().map(|p| anchored_wsp(p, 2009775, 819150, &geom(p), style, "")).collect(); + body.push_str(&anchored_wsp( + "Connector", + 2362200, + 390525, + r#""#, + style, + "", + )); + body.push_str(&anchored_wsp( + "Text Box", + 2324100, + 571500, + r#""#, + "", + r#"Floating text box"#, + )); + let shapes = |d: &Document| { + paras(d)[0] + .objects + .iter() + .map(|o| match o { + InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => { + (*kind, *w, *h, *fill, *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end) + } + o => panic!("{o:?}"), + }) + .collect::>() + }; + let d = read_body(&format!("{body}")); + let accent = d.settings.theme_colors[4]; + let read = shapes(&d); + let kinds: Vec = read.iter().map(|s| s.0).collect(); + assert_eq!( + kinds, + [ + ShapeKind::Rectangle, + ShapeKind::Ellipse, + ShapeKind::Triangle, + ShapeKind::Arrow, + ShapeKind::Star, + ShapeKind::StraightConnector, + ShapeKind::TextBox + ] + ); + for s in &read[..5] { + assert_eq!(s.3, Some(accent), "{:?} is filled from the theme", s.0); + assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0); + assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0); + } + assert!(read[5].9, "the connector keeps its arrowhead"); + let bytes = wordcraft_docx::write(&d).unwrap(); + let again = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(shapes(&again), read, "every shape survives saving"); + assert_eq!(part_text_box(&again), "Floating text box"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + assert_eq!(xml.matches("").count(), 7); + for p in presets.iter().chain(&["straightConnector1"]) { + assert!(xml.contains(&format!(r#""#)), "{p} is written"); + } +} + +/// The text of the first text box in `d`. +fn part_text_box(d: &Document) -> String { + let story = paras(d)[0].objects.iter().find_map(|o| match o { + InlineObject::Shape { kind: ShapeKind::TextBox, story, .. } => *story, + _ => None, + }); + story.and_then(|id| d.parts.get(&id)).map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).unwrap_or_default() +} + #[test] fn embedded_fonts_survive_a_round_trip() { let table = r#""#; From 408ae56f492e91ce7f208f92c82d83109e13341c Mon Sep 17 00:00:00 2001 From: mbaksa <116127013+mbaksa@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:00:58 +0200 Subject: [PATCH 020/199] Add Croatian spelling and grammar support (#1114) Co-authored-by: Brandon Thomas --- crates/proof/src/hr/grammar.rs | 169 ++++++++++++++ crates/proof/src/hr/mod.rs | 142 ++++++++++++ crates/proof/src/hr/suggest.rs | 365 +++++++++++++++++++++++++++++++ crates/proof/src/hr/test_dict.rs | 42 ++++ crates/proof/src/lang.rs | 7 +- crates/proof/src/lib.rs | 5 + crates/ui-egui/src/ribbon.rs | 4 +- 7 files changed, 732 insertions(+), 2 deletions(-) create mode 100644 crates/proof/src/hr/grammar.rs create mode 100644 crates/proof/src/hr/mod.rs create mode 100644 crates/proof/src/hr/suggest.rs create mode 100644 crates/proof/src/hr/test_dict.rs diff --git a/crates/proof/src/hr/grammar.rs b/crates/proof/src/hr/grammar.rs new file mode 100644 index 000000000..475a43e95 --- /dev/null +++ b/crates/proof/src/hr/grammar.rs @@ -0,0 +1,169 @@ +//! Conservative Croatian grammar and punctuation checks. +//! Handles repeated words, whitespace, capitals and duplicated punctuation; +//! intentionally avoids speculative agreement and comma rules. + +use crate::{Issue, IssueKind, ProofLang, words}; + +/// Words Croatian repeats on purpose: interjections, onomatopoeia and emphatic doubling. +const ALLOWED_REPEATS: &[&str] = + &["da", "ne", "no", "ha", "hej", "joj", "aj", "oh", "uh", "hop", "kuc", "tik", "tak", "bum", "malo", "brzo", "polako", "više", "dobro"]; +const ABBREVIATIONS: &[&str] = &[ + "npr", "itd", "itp", "tj", "tzv", "odn", "sl", "str", "br", "god", "st", "dr", "mr", "prof", "doc", "ing", "dipl", "mag", "univ", "g", "god", + "mj", "min", "sek", "čl", "st", "č", "toč", "tab", "sl", "sv", "ul", "trg", "pr", "izd", "ur", "svez", "vol", "vid", "usp", "prim", "engl", + "njem", "hrv", "lat", "tel", "mob", "e", "p", "o", "d", "m", "v", "z", "r", "pon", "uto", "sri", "čet", "pet", "sub", "ned", "sij", "velj", + "ožu", "tra", "svi", "lip", "srp", "kol", "ruj", "lis", "stu", "pro", +]; + +fn issue(start: usize, end: usize, message: String, suggestions: Vec) -> Issue { + Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang: ProofLang::Hr } +} + +fn capitalize(w: &str) -> String { + crate::affix::capitalize(w) +} + +/// Is `prev`, before a period, an abbreviation, an initial, a number or a Roman numeral (after +/// which a lower-case word doesn't start a sentence)? +fn abbreviation_or_number(prev: &str) -> bool { + prev.chars().count() <= 1 + || prev.chars().any(|c| c.is_ascii_digit()) + || (prev.chars().all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M'))) + || ABBREVIATIONS.contains(&prev.to_lowercase().as_str()) +} + +const CLOSING: [char; 8] = ['"', '”', '’', '\'', ')', '»', ']', '\u{1}']; +const OPENING: [char; 8] = ['"', '“', '«', '(', '[', '\'', '‚', '—']; + +/// Does the gap between two words end a sentence: a `.`, `!` or `?` (not an ellipsis, not after +/// an abbreviation), then space, with nothing but quotes and brackets around? +fn ends_sentence(gap: &str, prev: &str) -> bool { + let t = gap.trim_start_matches(CLOSING); + let Some(end) = t.chars().next() else { return false }; + if !matches!(end, '.' | '!' | '?') || t.starts_with("..") { + return false; + } + if end == '.' && abbreviation_or_number(prev) { + return false; + } + let rest = t.trim_start_matches(['.', '!', '?']).trim_start_matches(CLOSING); + rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| OPENING.contains(&c)) +} + +/// Grammar issues in a paragraph of Croatian text, in order. +pub fn check(text: &str) -> Vec { + let ws: Vec<(usize, usize)> = words(text); + let mut v = Vec::new(); + repeated_words(text, &ws, &mut v); + spaces(text, &mut v); + sentence_capitals(text, &ws, &mut v); + doubled_punctuation(text, &mut v); + v.sort_by_key(|i| (i.start, i.end)); + v +} + +fn repeated_words(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for pair in ws.windows(2) { + let [(a0, b0), (a1, b1)] = pair else { continue }; + let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue }; + if !gap.is_empty() + && gap.chars().all(char::is_whitespace) + && w0.chars().all(char::is_alphabetic) + && w0.to_lowercase() == w1.to_lowercase() + && !ALLOWED_REPEATS.contains(&w0.to_lowercase().as_str()) + { + v.push(issue(*a0, *b1, format!("Ponovljena riječ: \"{w1}\""), vec![w0.to_string()])); + } + } +} + +fn spaces(text: &str, v: &mut Vec) { + for (i, c) in text.char_indices() { + if c != ' ' { + continue; + } + let rest = text.get(i + 1..).unwrap_or(""); + let mut it = rest.chars(); + let Some(p) = it.next() else { continue }; + let after = it.next(); + let flag = match p { + ',' => true, + // `;)` and `;-)` are emoticons. + ';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')), + // ` ...` is an ellipsis, ` .5` a number. + '.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()), + // ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space. + ':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '’' | '!' | '?')), + _ => false, + }; + if flag { + v.push(issue(i, i + 1 + p.len_utf8(), "Razmak ispred interpunkcijskog znaka".into(), vec![p.to_string()])); + } + } + for (i, _) in text.match_indices(" ") { + if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { + continue; + } + v.push(issue(i, i + 2, "Dvostruki razmak".into(), vec![" ".into()])); + } +} + +fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + // List items (`– maçãs,`) may start in lower case. + let list_item = text.trim_end().ends_with([',', ';']); + for (k, &(a, b)) in ws.iter().enumerate() { + let Some(w) = text.get(a..b) else { continue }; + let starts = if k == 0 { + let before = text.get(..a).unwrap_or(""); + !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) + } else { + let Some(&(pa, pb)) = ws.get(k - 1) else { continue }; + ends_sentence(text.get(pb..a).unwrap_or(""), text.get(pa..pb).unwrap_or("")) + }; + if starts && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) { + v.push(issue(a, b, "Rečenica treba započeti velikim slovom".into(), vec![capitalize(w)])); + } + } +} + +fn doubled_punctuation(text: &str, v: &mut Vec) { + let bytes = text.as_bytes(); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + if !matches!(b, b',' | b'.' | b';') { + i += 1; + continue; + } + let run = bytes.get(i..).map_or(0, |r| r.iter().take_while(|&&x| x == b).count()); + let flag = match b { + b'.' => run == 2, + _ => run >= 2, + }; + if flag { + let end = i + run; + let suggestions: Vec = match b { + b'.' => vec![".".into(), "…".into()], + _ => vec![char::from(b).to_string()], + }; + v.push(issue(i, end, "Ponovljen interpunkcijski znak".into(), suggestions)); + } + i += run.max(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn checks_croatian_punctuation() { + let t = "Ovo ovo je primjer , s dva razmaka.."; + let issues = check(t); + assert!(issues.iter().any(|i| i.message.starts_with("Ponovljena riječ"))); + assert!(issues.iter().any(|i| i.message == "Dvostruki razmak")); + assert!(issues.iter().all(|i| i.lang == ProofLang::Hr)); + } + #[test] + fn abbreviations_do_not_trigger_capitals() { + let issues = check("To je npr. sasvim dobar primjer."); + assert!(!issues.iter().any(|i| i.message == "Rečenica treba započeti velikim slovom")); + } +} diff --git a/crates/proof/src/hr/mod.rs b/crates/proof/src/hr/mod.rs new file mode 100644 index 000000000..05b5a88b2 --- /dev/null +++ b/crates/proof/src/hr/mod.rs @@ -0,0 +1,142 @@ +//! Croatian spelling and conservative grammar support. +//! Uses an installed hr_HR Hunspell .aff/.dic pair (not bundled). +//! The engine decodes ISO-8859-2 dictionaries at load time. +//! Suggestions prioritize č/ć, đ, š, ž and common one-character mistakes. +//! Hyphenation remains the host engine's fallback until Croatian patterns are supplied. + +pub mod grammar; +mod suggest; +#[cfg(test)] +pub(crate) mod test_dict; + +use std::sync::{Arc, RwLock}; + +use crate::affix::AffixDict; + +pub use suggest::suggest; + +/// The Croatian dictionary: `None` until first use, then the installed one (or an empty one). +static DICT: RwLock>> = RwLock::new(None); + +/// File names of a Croatian Hunspell dictionary. +const NAMES: &[&str] = &["hr_HR", "hr-HR", "hr"]; + +/// The Croatian dictionary: the installed Hunspell `hr_HR` (read on first use), one given with +/// [`install_dictionary`], or an empty one when none is available (Croatian spelling is off +/// then; see [`has_dictionary`]). +pub fn dictionary() -> Arc { + if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + return d.clone(); + } + let loaded = Arc::new(load_installed()); + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + // Another thread (or an install) may have filled it meanwhile: that one wins. + slot.get_or_insert(loaded).clone() +} + +fn load_installed() -> AffixDict { + let Some((aff, dic)) = crate::hunspell::find(NAMES) else { + log::info!("Croatian spelling: no Hunspell hr_HR dictionary installed"); + return AffixDict::default(); + }; + + crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| { + log::warn!("Croatian spelling: {e}"); + AffixDict::default() + }) +} + +/// Use this Croatian dictionary from now on (Hunspell `.aff` and `.dic` text, decoded to +/// UTF-8), for example one the user picked. On error the current dictionary stays. +pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> { + let d = AffixDict::from_hunspell(aff, dic, &[])?; + *DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + Ok(()) +} + +/// Forget the loaded dictionary, so the next check looks for an installed one again (after +/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). +pub fn reload_dictionary() { + *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; +} + +/// Is a Croatian dictionary available (spelling is checked)? +pub fn has_dictionary() -> bool { + !dictionary().is_empty() +} + +/// Is `c` a letter of the Latin script (the only one the Croatian dictionary covers)? +fn is_latin_letter(c: char) -> bool { + c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c)) +} + +/// Is `word` spelled correctly in Croatian? Numbers, single letters, short words in capitals +/// (acronyms: `HRT`, `RH`), words with digits and words in other scripts are accepted, and so +/// is every word when no Croatian dictionary is installed. +pub fn is_correct(word: &str) -> bool { + is_correct_with(&dictionary(), word) +} + +/// [`is_correct`] against a given dictionary (an empty one accepts everything). +pub fn is_correct_with(d: &AffixDict, word: &str) -> bool { + if d.is_empty() { + return true; // no Croatian dictionary installed: no spelling marks + } + let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’'); + let w = w.trim_matches(['\'', '’']); + let n = w.chars().count(); + if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) { + return true; + } + if w.chars().all(|c| !c.is_lowercase()) && n <= 6 { + return true; + } + if !w.chars().any(is_latin_letter) { + return true; // other scripts: no dictionary + } + if w.contains('-') { + return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); + } + known(d, w) +} + +/// Is one word (no hyphens) in the dictionary or the user dictionary? +fn known(d: &AffixDict, w: &str) -> bool { + d.check(w) || crate::in_user_dictionary(&w.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dictionary_loads() { + test_dict::install(); + let d = dictionary(); + assert!(has_dictionary()); + assert!(d.len() > 15); + assert!(d.rule_count() > 0); + assert!(!d.map().is_empty() && !d.rep().is_empty() && !d.try_chars().is_empty()); + } + + #[test] + fn correct_words_and_affixes() { + test_dict::install(); + for w in ["kuća", "kuće", "računalo", "čovjek", "riječ", "dijete", "Hrvatska", "čokolada", "škola", "škole", "HRT", "2026"] { + assert!(is_correct(w), "{w}"); + } + } + + #[test] + fn misspellings_are_flagged() { + test_dict::install(); + for w in ["kuca", "racunalo", "covjek", "rijec", "dijeteh", "skolla"] { + assert!(!is_correct(w), "{w}"); + } + } + + #[test] + fn missing_dictionary_is_silent() { + assert!(is_correct_with(&AffixDict::default(), "nepostojećariječ")); + } +} diff --git a/crates/proof/src/hr/suggest.rs b/crates/proof/src/hr/suggest.rs new file mode 100644 index 000000000..875bf98c7 --- /dev/null +++ b/crates/proof/src/hr/suggest.rs @@ -0,0 +1,365 @@ +//! Croatian spelling suggestions using dictionary MAP/REP rules, edit distance, +//! missing spaces and a bounded search. Proper Croatian corrections depend on +//! the actual installed dictionary entries. + +use std::collections::{HashMap, HashSet}; + +use crate::affix::{AffixDict, Casing, capitalize, casing}; + +/// Short words often run into the next one (`não é`, `da água`, `para isso`). +const LEADING_WORDS: &[&str] = &[ + "a", "ali", "i", "ili", "da", "do", "od", "na", "ne", "ni", "o", "po", "pod", "pri", "sa", "s", "se", "su", "u", "uz", "za", "iz", "je", "će", + "bi", "bih", "što", "koji", "koja", "koje", "kao", "kad", "dok", "bez", "pred", "nad", +]; +const TRAILING_WORDS: &[&str] = + &["je", "su", "se", "ga", "mi", "ti", "mu", "joj", "ih", "im", "li", "će", "ću", "bi", "bih", "i", "a", "u", "na", "za", "od", "do"]; + +const MAX_LETTERS: usize = 40; +const MAX_MAP_CHANGES: usize = 4; +const MAX_MAP_VARIANTS: usize = 4096; +/// Stems the scan of round 6 expands. +const SCAN_STEMS: usize = 48; + +const COST_CASE: u32 = 0; +const COST_ADD_DIACRITIC: u32 = 4; +const COST_OTHER_MAP: u32 = 7; +const COST_DROP_DIACRITIC: u32 = 11; +const COST_REP: u32 = 10; +const COST_SPLIT: u32 = 12; +const COST_SPLIT_RARE: u32 = 18; +const COST_SWAP: u32 = 14; +const COST_DROP_OR_ADD: u32 = 15; +const COST_MISTYPE: u32 = 16; +const COST_SCAN: u32 = 20; + +struct Collector<'a> { + d: &'a AffixDict, + /// The word, lower case. + input: String, + tested: HashSet, + /// Suggestion → (cost, order found). + found: HashMap, + seq: usize, +} + +impl Collector<'_> { + /// Check a lower-case candidate (a word, or two separated by a space) and keep it if every + /// word is in the dictionary, as written or capitalized (names). + fn offer(&mut self, cand: &str, cost: u32) { + if cand.is_empty() || cand == self.input || !self.tested.insert(cand.to_string()) { + return; + } + let mut words = Vec::new(); + let mut cost = cost; + for part in cand.split(' ') { + if self.d.contains(part) { + words.push(part.to_string()); + } else { + let c = capitalize(part); + if !self.d.contains(&c) { + return; + } + words.push(c); + // A name ranks after a common word at the same distance (`moça`, then `Moça`). + cost += 1; + } + } + self.keep(words.join(" "), cost); + } + + /// Keep a suggestion known to be correct. + fn keep(&mut self, text: String, cost: u32) { + self.seq += 1; + let seq = self.seq; + let e = self.found.entry(text).or_insert((cost, seq)); + if cost < e.0 { + *e = (cost, seq); + } + } + + fn count_within(&self, cost: u32) -> usize { + self.found.values().filter(|(c, _)| *c <= cost).count() + } +} + +/// The MAP group of each letter (its other members are its likely confusions). +fn alternatives(c: char, map: &[Vec]) -> Vec { + map.iter().filter(|g| g.contains(&c)).flat_map(|g| g.iter().copied()).filter(|&x| x != c).collect() +} + +/// The cost of changing letter `from` to its MAP relative `to`. Adding a missing accent is the +/// commonest slip (Croatian typed without accents: `coracao`); an accent the writer did type +/// is likelier right, so taking one away costs more than a known confusion from the REP table +/// (`seje` → `seja` before `sejé`). +fn map_cost(from: char, to: char) -> u32 { + match (from.is_ascii(), to.is_ascii()) { + (true, false) => COST_ADD_DIACRITIC, + (false, true) => COST_DROP_DIACRITIC, + _ => COST_OTHER_MAP, + } +} + +/// Every way of changing up to [`MAX_MAP_CHANGES`] letters to a MAP relative, with its cost. +fn map_variants(chars: &[char], alts: &[Vec], i: usize, changes: usize, cost: u32, buf: &mut Vec, out: &mut Vec<(String, u32)>) { + if out.len() >= MAX_MAP_VARIANTS { + return; + } + let Some(&c) = chars.get(i) else { + if changes > 0 { + out.push((buf.iter().collect(), cost)); + } + return; + }; + buf.push(c); + map_variants(chars, alts, i + 1, changes, cost, buf, out); + buf.pop(); + if changes < MAX_MAP_CHANGES { + for &a in alts.get(i).map(Vec::as_slice).unwrap_or_default() { + buf.push(a); + map_variants(chars, alts, i + 1, changes + 1, cost + map_cost(c, a), buf, out); + buf.pop(); + } + } +} + +/// A letter's MAP representative (the first of its group), lower case: `ã` → `a`, `ç` → `c`. +fn fold(c: char, map: &[Vec]) -> char { + let l = c.to_lowercase().next().unwrap_or(c); + map.iter().find(|g| g.contains(&l)).and_then(|g| g.first().copied()).unwrap_or(l) +} + +/// Optimal string alignment distance where a MAP slip (`a`/`á`, `c`/`ç`) costs 1 and any other +/// edit 2; `limit + 1` once it is certainly above `limit`. +fn weighted_distance(a: &[char], b: &[char], related: &dyn Fn(char, char) -> bool, limit: u32) -> u32 { + let m = b.len(); + let over = limit + 1; + let mut prev2 = vec![0u32; m + 1]; + let mut prev: Vec = (0..=m as u32).map(|j| j * 2).collect(); + let mut cur = vec![0u32; m + 1]; + for (i, &ai) in a.iter().enumerate() { + if let Some(c) = cur.get_mut(0) { + *c = (i as u32 + 1) * 2; + } + let mut row_min = u32::MAX; + for (j, &bj) in b.iter().enumerate() { + let sub = if ai == bj { + 0 + } else if related(ai, bj) { + 1 + } else { + 2 + }; + let at = |v: &[u32], k: usize| v.get(k).copied().unwrap_or(u32::MAX / 4); + let mut v = (at(&prev, j + 1) + 2).min(at(&cur, j) + 2).min(at(&prev, j) + sub); + if i > 0 && j > 0 && a.get(i - 1) == Some(&bj) && b.get(j - 1) == Some(&ai) { + v = v.min(at(&prev2, j - 1) + 2); + } + if let Some(c) = cur.get_mut(j + 1) { + *c = v; + } + row_min = row_min.min(v); + } + if m > 0 && row_min > limit { + return over; + } + std::mem::swap(&mut prev2, &mut prev); + std::mem::swap(&mut prev, &mut cur); + } + prev.get(m).copied().unwrap_or(over).min(over) +} + +/// Spelling suggestions for a Croatian word, best first, at most `max`, in the word's letter case. +pub fn suggest(word: &str, max: usize) -> Vec { + suggest_in(&super::dictionary(), word, max) +} + +/// [`suggest`] against a given dictionary. +pub fn suggest_in(d: &AffixDict, word: &str, max: usize) -> Vec { + let word = word.trim(); + let n = word.chars().count(); + if n == 0 || n > MAX_LETTERS || max == 0 || d.is_empty() || word.contains(char::is_whitespace) { + return Vec::new(); + } + let case = casing(word); + let input = word.to_lowercase(); + let chars: Vec = input.chars().collect(); + let mut col = Collector { d, input: input.clone(), tested: HashSet::new(), found: HashMap::new(), seq: 0 }; + // 1. Letter case. + if d.contains(&input) && word != input { + col.keep(input.clone(), COST_CASE); + } else if d.contains(&capitalize(&input)) && word != capitalize(&input) { + col.keep(capitalize(&input), COST_CASE); + } + // 2. Accents and other MAP slips. + let fallback_map = vec![vec!['c', 'č', 'ć'], vec!['d', 'đ'], vec!['s', 'š'], vec!['z', 'ž']]; + let map = if d.map().is_empty() { &fallback_map } else { d.map() }; + let alts: Vec> = chars.iter().map(|&c| alternatives(c, map)).collect(); + let mut variants = Vec::new(); + map_variants(&chars, &alts, 0, 0, 0, &mut Vec::with_capacity(n), &mut variants); + variants.sort_by_key(|v| v.1); + for (v, cost) in &variants { + col.offer(v, *cost); + } + // 3. Common confusions. + for (from, to) in d.rep() { + if from.is_empty() { + continue; + } + for (i, _) in input.match_indices(from.as_str()) { + if let (Some(a), Some(b)) = (input.get(..i), input.get(i + from.len()..)) { + col.offer(&format!("{a}{to}{b}"), COST_REP); + } + } + } + // 4. A missing space: cheap after a short leading word or before a short trailing one, + // dearer between two longer words, never leaving a stray short half. + for (i, _) in input.char_indices().skip(1) { + let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue }; + let long = |p: &str| p.chars().count() >= 3; + let cost = if (LEADING_WORDS.contains(&a) && long(b)) || (long(a) && TRAILING_WORDS.contains(&b)) { + COST_SPLIT + } else if long(a) && long(b) { + COST_SPLIT_RARE + } else { + continue; + }; + col.offer(&format!("{a} {b}"), cost); + } + // 5. One typo. + let mut letters: Vec = d.try_chars().chars().filter(|c| c.is_lowercase()).collect(); + if letters.is_empty() { + letters = "aeiourntsjkvlmdpbgczčćšžđfh".chars().collect(); + } + let s = |v: &[char]| v.iter().collect::(); + for i in 0..n.saturating_sub(1) { + if chars.get(i) != chars.get(i + 1) { + let mut v = chars.clone(); + v.swap(i, i + 1); + col.offer(&s(&v), COST_SWAP); + } + } + for i in 0..n { + let mut v = chars.clone(); + v.remove(i); + col.offer(&s(&v), COST_DROP_OR_ADD); + } + for i in 0..=n { + for &c in &letters { + let mut v = chars.clone(); + v.insert(i, c); + col.offer(&s(&v), COST_DROP_OR_ADD); + } + } + for i in 0..n { + for &c in &letters { + if chars.get(i) != Some(&c) { + let mut v = chars.clone(); + if let Some(x) = v.get_mut(i) { + *x = c; + } + col.offer(&s(&v), COST_MISTYPE); + } + } + } + // 6. Further off: scan the stems that start like the word. + if col.count_within(COST_MISTYPE) < max { + scan(&mut col, &chars); + } + let mut found: Vec<(String, (u32, usize))> = col.found.into_iter().collect(); + // Among equal costs, the change nearest the word's start is the likelier intent + // (`saúde` before `saudê`: both add an accent, but at the first difference). + let diff_at = |text: &str| chars.iter().zip(text.chars()).position(|(a, b)| *a != b).unwrap_or(n); + found.sort_by_key(|(text, (cost, seq))| (*cost, diff_at(text), *seq)); + let mut out: Vec = Vec::new(); + for (text, _) in found { + let t = match case { + Casing::Initial => capitalize(&text), + Casing::Upper if n > 1 => text.to_uppercase(), + _ => text, + }; + if t != word && !out.contains(&t) { + out.push(t); + } + if out.len() >= max { + break; + } + } + out +} + +/// Round 6: rank the stems starting with the word's first letter (or a MAP relative of it, either +/// case) by shared letter pairs, then keep the forms of the best within two typos. +fn scan(col: &mut Collector, chars: &[char]) { + let d = col.d; + let map = d.map(); + let Some(&first) = chars.first() else { return }; + let folded: Vec = chars.iter().map(|&c| fold(c, map)).collect(); + let pairs: HashSet<(char, char)> = std::iter::once(' ').chain(folded.iter().copied()).zip(folded.iter().copied()).collect(); + let mut firsts: Vec = std::iter::once(first).chain(alternatives(first, map)).collect(); + let upper: Vec = firsts.iter().flat_map(|c| c.to_uppercase()).collect(); + firsts.extend(upper); + firsts.sort_unstable(); + firsts.dedup(); + let n = folded.len(); + let mut best: Vec<(u32, &str)> = Vec::new(); + for f in firsts { + let prefix = f.to_string(); + for stem in d.stems_with_prefix(&prefix) { + let sf: Vec = stem.chars().map(|c| fold(c, map)).collect(); + let shared = std::iter::once(' ').chain(sf.iter().copied()).zip(sf.iter().copied()).filter(|p| pairs.contains(p)).count(); + if shared < 2 { + continue; + } + // Shared pairs, less the stem's letters the word doesn't explain, less a length gap + // beyond what an ending explains. + let extra = sf.len().saturating_sub(shared); + let gap = sf.len().abs_diff(n).saturating_sub(4); + let score = (shared * 4).saturating_sub(extra * 3 + gap * 2) as u32; + if score == 0 { + continue; + } + if best.len() < SCAN_STEMS { + best.push((score, stem)); + } else if let Some(min) = best.iter_mut().min_by_key(|b| b.0) + && score > min.0 + { + *min = (score, stem); + } + } + } + best.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1))); + let related = |a: char, b: char| fold(a, map) == fold(b, map); + let limit = 4; + for (_, stem) in best { + for form in d.forms(stem) { + let lf: Vec = form.to_lowercase().chars().collect(); + if lf.len().abs_diff(chars.len()) > 2 { + continue; + } + let dist = weighted_distance(&lf, chars, &related, limit); + if dist <= limit && lf.as_slice() != chars { + col.keep(form, COST_SCAN + dist * 3); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn croatian_suggestions() { + crate::hr::test_dict::install(); + for (wrong, right) in [("kuca", "kuća"), ("racunalo", "računalo"), ("covjek", "čovjek"), ("rijec", "riječ"), ("skolla", "škola")] { + let result = suggest(wrong, 10); + assert!(result.iter().any(|s| s == right), "{wrong} -> {result:?}, expected {right}"); + } + } + #[test] + fn bounded() { + crate::hr::test_dict::install(); + assert!(suggest("", 5).is_empty()); + assert!(suggest("kuca", 0).is_empty()); + assert!(suggest(&"a".repeat(41), 5).is_empty()); + } +} diff --git a/crates/proof/src/hr/test_dict.rs b/crates/proof/src/hr/test_dict.rs new file mode 100644 index 000000000..038069e83 --- /dev/null +++ b/crates/proof/src/hr/test_dict.rs @@ -0,0 +1,42 @@ +//! Small synthetic UTF-8 Croatian Hunspell dictionary for deterministic tests. +pub const AFF: &str = "SET UTF-8 +TRY abcčćdđefghijklmnoprsštuvzž +MAP 5 +MAP cčć +MAP dđ +MAP sš +MAP zž +MAP iije +REP 3 +REP c č +REP dj đ +REP je ije +SFX A Y 2 +SFX A a e a +SFX A a i a +"; +pub const DIC: &str = "18 +kuća/A +računalo +čovjek +riječ +dijete +Hrvatska +čokolada +škola/A +more +sunce +dobar +hrvatski +jezik +tekst +pogreška +pravopis +provjera +računala +"; +pub fn install() { + if let Err(e) = super::install_dictionary(AFF, DIC) { + panic!("test dictionary: {e}"); + } +} diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index 465913bb7..48ef51aa8 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -24,6 +24,8 @@ pub enum ProofLang { /// Italian (`it`, `it-IT`, `it-CH`): the installed Hunspell dictionary (`it_IT`, when there /// is one), the Italian grammar rules and the Italian hyphenation patterns. It, + /// Croatian (`hr`, `hr-HR`) + Hr, /// A language without proofing tools here (`fr-FR`, `ja-JP`, `x-none`…). Like Word without /// the language's proofing tools: no spelling or grammar marks, so its words are never /// flagged as English misspellings. Hyphenation keeps using the English patterns, as before @@ -33,7 +35,7 @@ pub enum ProofLang { impl ProofLang { /// Every language with a spelling dictionary, in display order. - pub const PROOFED: [ProofLang; 4] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It]; + pub const PROOFED: [ProofLang; 5] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr]; /// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). Only the primary subtag /// counts. No tag (or an empty one) is English, WordCraft's default editing language. @@ -50,6 +52,8 @@ impl ProofLang { ProofLang::Pt } else if primary.eq_ignore_ascii_case("it") || primary.eq_ignore_ascii_case("ita") { ProofLang::It + } else if primary.eq_ignore_ascii_case("hr") || primary.eq_ignore_ascii_case("hrv") { + ProofLang::Hr } else { ProofLang::Other } @@ -63,6 +67,7 @@ impl ProofLang { ProofLang::Bg => "bg", ProofLang::Pt => "pt", ProofLang::It => "it", + ProofLang::Hr => "hr", ProofLang::Other => "", } } diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index adfcf1eba..f9eead19d 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -34,6 +34,7 @@ pub mod affix; pub mod bg; pub mod dict; +pub mod hr; pub mod hunspell; pub mod hyphen; pub mod it; @@ -504,6 +505,7 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option } let message = match lang { ProofLang::Pl => "Możliwy błąd pisowni", + ProofLang::Hr => "Moguća pravopisna pogreška", ProofLang::Pt => "Possível erro de ortografia", ProofLang::It => "Possibile errore di ortografia", _ => "Possible spelling mistake", @@ -521,6 +523,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { match lang { ProofLang::En => is_correct_with(word, opts), ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word), + ProofLang::Hr => accepted_by_options(word, opts) || hr::is_correct(word), ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word), ProofLang::It => accepted_by_options(word, opts) || it::is_correct(word), ProofLang::Bg | ProofLang::Other => true, @@ -532,6 +535,7 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { match lang { ProofLang::En => suggest(word, max), ProofLang::Pl => pl::suggest(word, max), + ProofLang::Hr => hr::suggest(word, max), ProofLang::Pt => pt::suggest(word, max), ProofLang::It => it::suggest(word, max), ProofLang::Bg | ProofLang::Other => Vec::new(), @@ -545,6 +549,7 @@ pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { match lang { ProofLang::En => check_grammar(text), ProofLang::Pl => pl::grammar::check(text), + ProofLang::Hr => hr::grammar::check(text), ProofLang::Pt => pt::grammar::check(text), ProofLang::It => it::grammar::check(text), ProofLang::Bg | ProofLang::Other => Vec::new(), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cc22e3350..c6ac30bc4 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -410,12 +410,13 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) } /// The proofing languages the Language menu offers: tag and English name (translated when drawn). -pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 5] = [ +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 6] = [ ("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish"), ("pt-BR", "Portuguese (Brazil)"), ("it-IT", "Italian"), + ("hr-HR", "Croatian"), ]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's @@ -427,6 +428,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { } match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), + wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(), wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), wordcraft_engine::proof::ProofLang::It => "Italian".into(), wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), From d6723153ccce0ca2ce90aa0879bda441117e5e4c Mon Sep 17 00:00:00 2001 From: naxvog Date: Wed, 14 Oct 2026 08:01:09 +0200 Subject: [PATCH 021/199] Drawings: VML text boxes keep their margins, fit and alignment; hidden drawings are kept but not shown (#1118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Text boxes: VML text boxes keep their internal margins, fit to their text and alignment A VML text box's inset ("l,t,r,b", ECMA-376 Part 4 §19.1.2.22) was ignored, so its text sat Word's default 7.2 pt in and 3.6 pt down even where the file says 0,0,0,0, which is how letterheads and form headers place address blocks exactly. It's now read like DrawingML's lIns/tIns/rIns/bIns: each value left out keeps the default, a bare number is in EMUs, o:insetmode="auto" leaves the defaults. mso-fit-shape-to-text (VML's "Resize shape to fit text") is read as auto-fit, like a:spAutoFit, and the shape's v-text-anchor as the text's vertical alignment. In a group, a member's margins go with that member's text. The reader feeds layout, so the editor, PDF and page images all change. Co-Authored-By: Claude Opus 5.5 * Drawings: hidden shapes and pictures are kept but not shown A drawing marked hidden (VML visibility:hidden, floating or inline; DrawingML wp:docPr/@hidden, or a group member's cNvPr/@hidden) was drawn, took room and wrapped text. Word for Windows shows it in no view; it's now kept and saved hidden, but left out of layout: not placed, no wrap area, no room in its line, nothing drawn, its text box's notes not placed. Exports to other formats and copies leave it out, the accessibility checker no longer asks for its alt text, and the selection pane says it's hidden. Word for Mac draws hidden VML shapes; Windows follows the markup. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/doc/src/lib.rs | 7 ++- crates/doc/src/para.rs | 4 ++ crates/docx/src/read/story.rs | 51 +++++++++++++-- crates/docx/src/write/story.rs | 11 ++-- crates/docx/tests/text_boxes.rs | 87 ++++++++++++++++++++++++++ crates/engine/src/cmd/accessibility.rs | 5 +- crates/engine/src/cmd/objects.rs | 3 +- crates/formats/src/model.rs | 3 +- crates/formats/src/tests.rs | 13 ++++ crates/layout/src/display.rs | 3 + crates/layout/src/lib.rs | 26 +++++--- crates/layout/src/para.rs | 2 + crates/layout/src/tests.rs | 50 +++++++++++++++ docs/file-format-parity.md | 7 ++- 15 files changed, 246 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 232233225..4a9add834 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | | 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo | | 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet | | 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | @@ -276,6 +277,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | | 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3f8a9e43a..d6f372de1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -283,10 +283,11 @@ pub struct TextBody { /// story; its own blocks are unused. See [`Document::text_box_chains`]. pub next: Option, /// Internal margins (left, top, right, bottom), EMU, when the file gives its own - /// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1); `None` is Word's defaults. + /// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`); + /// `None` is Word's defaults. pub inset: Option<[u32; 4]>, - /// Resize shape to fit text (`a:spAutoFit`): Word grows the box to its text, so none of - /// the text is cut. + /// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the + /// box to its text, so none of the text is cut. pub auto_fit: bool, } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 5f936b9d2..601eaa55d 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -212,6 +212,10 @@ pub struct Float { /// decorative mark). See [`InlineObject::alt_text`]. #[serde(skip_serializing_if = "AltText::is_empty")] pub alt: AltText, + /// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor + /// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes). + #[serde(skip_serializing_if = "is_false")] + pub hidden: bool, } /// Longest alternative text kept (characters): hostile files and commands are capped. diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 0ece8d837..7c50ebb07 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -893,6 +893,7 @@ impl Reader<'_> { let (alt, meta) = nv_alt(c.child("wp:docPr")); let mut float = if anchored { anchor_float(c) } else { Float::default() }; float.alt = meta; + float.hidden = c.child("wp:docPr").is_some_and(|p| on_off_attr(p, "hidden")); if let Some(e) = c.child("wp:effectExtent") { for (slot, n) in float.effect.iter_mut().zip(["l", "t", "r", "b"]) { // Negative for a rotated object narrower than its frame; settled below. @@ -1165,8 +1166,9 @@ impl Reader<'_> { let max = crate::units::MAX_LEN_PT; let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max); let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0)); - let (alt, meta) = nv_alt(if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") }); - let float = Float { alt: meta, ..Float::default() }; + let nv = if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") }; + let (alt, meta) = nv_alt(nv); + let float = Float { alt: meta, hidden: nv.is_some_and(|p| on_off_attr(p, "hidden")), ..Float::default() }; let obj = if e.name == "pic:pic" { self.read_pic(e, rels, w, h, alt.clone(), float) } else { @@ -1310,10 +1312,35 @@ impl Reader<'_> { let stroke = on("stroked").then(|| shape.attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten(); let stroke_width = shape.attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0); let fill = on("filled").then(|| shape.attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten(); - if let Some(t) = shape.find("w:txbxContent") + if let Some((tb, t)) = vml_textbox(shape, 0) && sc.story_depth < MAX_STORY_DEPTH { - let story = Some(self.read_textbox(sc, t, rels)); + let id = self.read_textbox(sc, t, rels); + if let Some(part) = self.doc.parts.get_mut(&id) { + // Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left + // out keeping Word's default; a bare number is in EMUs, VML's base unit. + // `o:insetmode="auto"` leaves them to the application. + let mut inset = wordcraft_doc::TEXT_BOX_INSET; + let custom = tb.attr("o:insetmode").is_none_or(|m| !m.eq_ignore_ascii_case("auto")); + for (slot, v) in inset.iter_mut().zip(tb.attr("inset").filter(|_| custom).unwrap_or("").split(',')) { + if let Some(pt) = measure(v, 12_700.0) { + *slot = (pt.max(0.0) * 12_700.0).round().min(wordcraft_doc::MAX_TEXT_BOX_INSET as f32) as u32; + } + } + part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset); + // Resize shape to fit text (DrawingML's `a:spAutoFit`). + let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text"); + part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true")); + // Where the text sits between the box's top and bottom (DrawingML's `anchor`). + if let Some(a) = vml_style(style, "v-text-anchor") { + part.body.anchor = match a { + a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center, + a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom, + _ => wordcraft_doc::props::VAlign::Top, + }; + } + } + let story = Some(id); return Some(InlineObject::Shape { kind: ShapeKind::TextBox, w, @@ -1590,6 +1617,18 @@ fn wrap_polygon(e: &El) -> 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. +fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> { + if depth > crate::xml::MAX_DEPTH { + return None; + } + e.els().find_map(|c| match c.child("w:txbxContent") { + Some(t) if c.name == "v:textbox" => Some((c, t)), + _ => vml_textbox(c, depth + 1), + }) +} + fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> { style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim()) } @@ -1602,9 +1641,10 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> { /// `z-index`. Anything else stays inline. fn vml_float(shape: &El, style: &str) -> Float { let get = |k: &str| vml_style(style, k); + let hidden = get("visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden")); if !get("position").is_some_and(|p| p.eq_ignore_ascii_case("absolute")) { // Inline: only the turn (an OLE object turned in WordCraft is saved so, #319). - let mut f = Float::default(); + let mut f = Float { hidden, ..Float::default() }; f.set_spin(vml_spin(style)); return f; } @@ -1651,6 +1691,7 @@ fn vml_float(shape: &El, style: &str) -> Float { dist: dist("mso-wrap-distance-left").max(dist("mso-wrap-distance-right")), dist_top: dist("mso-wrap-distance-top"), dist_bottom: dist("mso-wrap-distance-bottom"), + hidden, ..Float::default() }; f.set_spin(vml_spin(style)); diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index f53d10a0e..d894f6715 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -891,7 +891,7 @@ impl Writer<'_> { self.used_media.insert(media.clone()); w.open("pic:pic", &[]); w.open("pic:nvPicPr", &[]); - nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt); + nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden); w.open("pic:cNvPicPr", &[]); w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]); w.close("pic:cNvPicPr"); @@ -923,7 +923,7 @@ impl Writer<'_> { w.open("wps:wsp", &[]); if let Some(id) = id { let name = shape_name(*kind, freeform.as_deref(), id); - nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt); + nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden); } if kind.is_connector() { // A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`). @@ -1139,7 +1139,7 @@ impl Writer<'_> { Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]), Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]), } - nv_props(w, "wp:docPr", docpr, name, alt, &float.alt); + nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden); } fn table(&mut self, w: &mut W, t: &Table, rels: &mut PartRels, depth: usize) { @@ -1542,7 +1542,7 @@ fn toc_span(bl: &Blocks) -> Option<(usize, usize)> { /// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as /// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read /// from a file with no description) is not written twice. -pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText) { +pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) { let mut attrs = vec![("id", id), ("name", name)]; if descr != alt.title || descr.is_empty() { attrs.push(("descr", descr)); @@ -1550,6 +1550,9 @@ pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, if !alt.title.is_empty() { attrs.push(("title", alt.title.as_str())); } + if hidden { + attrs.push(("hidden", "1")); + } if !alt.decorative { w.empty(tag, &attrs); return; diff --git a/crates/docx/tests/text_boxes.rs b/crates/docx/tests/text_boxes.rs index 2831c9ca4..7efe7b040 100644 --- a/crates/docx/tests/text_boxes.rs +++ b/crates/docx/tests/text_boxes.rs @@ -108,3 +108,90 @@ fn hostile_links_read_without_chains_running_wild() { let b = r.parts.get(&got[0]).unwrap().body; assert_eq!((b.vert, b.anchor), (TextVert::Vert, VAlign::Center)); } + +#[test] +fn vml_text_boxes_keep_their_inset_fit_and_anchor() { + // ECMA-376 Part 4 §19.1.2.22: `inset="l,t,r,b"`, each one left out keeping the default + // (0.1in, 0.05in); `mso-fit-shape-to-text` resizes the shape to its text; `v-text-anchor` + // places the text between the box's top and bottom. + let content = r#"Boxed"#; + let shape = |style: &str, textbox: &str| { + format!( + r#"{content}"# + ) + }; + let body = [ + shape("", r#"inset="0,0,0,0""#), + shape("v-text-anchor:middle", r#"inset="1mm,,2pt" style="mso-fit-shape-to-text:T""#), + shape("", ""), + // A bare number is in EMUs; one that doesn't parse keeps the default; negative is 0; + // too large is the most a margin may be. + shape("v-text-anchor:bottom-baseline", r#"inset="12700,bogus,-5pt,99in""#), + // `o:insetmode="auto"` leaves the margins to the application. + shape("", r#"o:insetmode="auto" inset="0,0,0,0""#), + // In a group, a member's margins go with that member's text, not an empty box's before it. + format!(r#"{content}"#), + ] + .concat(); + let doc = format!( + r#"{body}"# + ); + let rels = br#""#; + let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap(); + let bodies: Vec = boxes(&d).iter().map(|id| d.parts[id].body).collect(); + assert_eq!(bodies.len(), 6); + assert_eq!(bodies[0].inset, Some([0, 0, 0, 0])); + assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center)); + assert_eq!(bodies[2], TextBody::default()); + assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom)); + assert_eq!(bodies[4].inset, None); + assert_eq!(bodies[5].inset, Some([0, 0, 0, 0])); + // Saved and read again, they stay. + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + let again: Vec = boxes(&back).iter().map(|id| back.parts[id].body).collect(); + assert_eq!(again, bodies); +} + +#[test] +fn hidden_drawings_stay_hidden() { + // VML `visibility:hidden` (floating or inline) and DrawingML `wp:docPr/@hidden` (also a group + // member's `wps:cNvPr/@hidden`) mark a drawing hidden; it is kept, and saved hidden. + let vml = r#""#; + let shown = r#""#; + let inline_vml = r#""#; + let dml = r#""#; + let member = |id: u32, hidden: &str| { + format!( + r#""# + ) + }; + let group = format!( + r#"{}{}"#, + member(9, r#" hidden="1""#), + member(10, "") + ); + let doc = format!( + r#"{vml}{shown}{inline_vml}{dml}{group}"# + ); + let rels = br#""#; + // Each drawing's flag, a group's members' after it. + let hidden = |d: &Document| { + let mut out = Vec::new(); + d.objects_in_reading_order(&mut |o| { + out.extend(o.frame().map(|(_, _, f)| f.hidden)); + if let InlineObject::Group { children, .. } = o { + out.extend(children.iter().filter_map(|c| c.obj.frame()).map(|(_, _, f)| f.hidden)); + } + }); + out + }; + let want = [true, false, true, true, false, true, false]; + let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap(); + assert_eq!(hidden(&d), want); + let saved = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&saved); + let marked = + |tag: &str| xml.split(&format!("<{tag} ")).skip(1).filter(|a| a.split('>').next().is_some_and(|a| a.contains(r#"hidden="1""#))).count(); + assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}"); + assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want); +} diff --git a/crates/engine/src/cmd/accessibility.rs b/crates/engine/src/cmd/accessibility.rs index c6044e48a..ab579011e 100644 --- a/crates/engine/src/cmd/accessibility.rs +++ b/crates/engine/src/cmd/accessibility.rs @@ -114,8 +114,9 @@ impl Checker<'_> { let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) else { continue }; let pos = at(path, off); let select = json!({"anchor": pos, "focus": at(path, off + OBJ.len_utf8())}); - // Ink: every stroke is its own object, so asking about each would bury the report. - if o.is_decorative() || o.ink().is_some() { + // Ink: every stroke is its own object, so asking about each would bury the report. A + // hidden drawing isn't shown, so nobody reads it. + if o.is_decorative() || o.ink().is_some() || o.frame().is_some_and(|(_, _, f)| f.hidden) { continue; } let titled = o.frame().is_some_and(|(_, _, f)| !f.alt.title.trim().is_empty()); diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 11493302c..1bf3e8a6f 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -1342,7 +1342,8 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult { InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)), _ => ("object", format!("Object {}", i + 1)), }; - json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)}) + let hidden = o.frame().is_some_and(|(_, _, f)| f.hidden); + json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o), "hidden": hidden}) }) .collect(), )) diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index c80cbf5de..6e8b0128a 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -778,7 +778,8 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { } let obj = p.objects.get(k); k += 1; - if hidden { + // Hidden text, or a hidden drawing: not in the copy. + if hidden || obj.and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) { continue; } match obj { diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 8dea5bf98..d22e5f695 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -677,3 +677,16 @@ fn exported_links_keep_only_safe_targets() { assert!(!md.contains(&format!("[{text}](")), "md: {md}"); } } + +#[test] +fn hidden_drawings_are_left_out_of_exports() { + let mut d = wordcraft_doc::Document::from_text("x"); + let key = d.add_media(vec![0x89, b'P', b'N', b'G'], "png"); + for hidden in [false, true] { + let float = wordcraft_doc::para::Float { hidden, ..Default::default() }; + let image = wordcraft_doc::InlineObject::Image { media: key.clone(), w: 10.0, h: 10.0, alt: "Logo".into(), float, crop: [0.0; 4], ole: None }; + d.insert_object(&wordcraft_doc::Pos::body(0, 0), image, &Default::default()).unwrap(); + } + // Two pictures, one hidden: one comes out. + assert_eq!(crate::html::export(&d).matches(" { let mut members = Vec::new(); for ([x, y, w, h], c) in o.group_rects(rect.x, rect.y, rect.w, rect.h) { + if c.frame().is_some_and(|(_, _, f)| f.hidden) { + continue; + } object_draws(c, Rect::new(x, y, w, h), own.within(outer), alpha, opts, &mut members); } match o.canvas_style() { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 0261333a3..727cff1fe 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -734,12 +734,13 @@ impl Ctx<'_> { } /// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden -/// text unless `show_hidden`, or in a tracked deletion with `hide_deleted`. `table_chr` is the +/// text unless `show_hidden`, in a tracked deletion with `hide_deleted`, or a hidden drawing +/// (`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 { let mut left = Vec::new(); - // Nothing can be left out with hidden text shown and markup on. + // 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() { let style = p.props.style.as_deref(); let is_left = |c: &CharProps| { @@ -766,7 +767,8 @@ fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps> left.push(l); } } - move |k| left.get(k).copied().unwrap_or(false) + // A hidden drawing is left out whatever is shown (Word shows it in no view). + move |k| left.get(k).copied().unwrap_or(false) || p.objects.get(k).and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) } /// Footnote and endnote numbers (each kind counted separately), in document order, as each @@ -785,15 +787,16 @@ fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap) -> let mut order = [0u32; 2]; let mut si = 0usize; // Reading order, text boxes included where they are. A hidden reference mark still takes - // its number, as in Word; a deleted one (or one in a deleted text box) does not. + // its number, as in Word; a deleted one (or one in a deleted or hidden text box) does not, + // and `para_notes` doesn't place its note either. let mut cur: Option<(usize, Vec)> = None; doc.objects_in_reading_order_by_block(&mut |block, p, k| { let key = p as *const Paragraph as usize; if cur.as_ref().is_none_or(|(at, _)| *at != key) { - let deleted = left_out_objects(doc, p, None, true, hide_deleted); - cur = Some((key, (0..p.objects.len()).map(deleted).collect())); + let gone = left_out_objects(doc, p, None, true, hide_deleted); + cur = Some((key, (0..p.objects.len()).map(gone).collect())); } - if cur.as_ref().is_some_and(|(_, deleted)| deleted.get(k).copied().unwrap_or(false)) { + if cur.as_ref().is_some_and(|(_, gone)| gone.get(k).copied().unwrap_or(false)) { return false; } if let Some(InlineObject::NoteRef { kind, id, .. }) = p.objects.get(k) { @@ -2504,7 +2507,12 @@ fn carried(rect: Rect, spin: Spin, centre: Point) -> Rect { } fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> { - o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect() + // Hidden members aren't placed (nor their text boxes laid out). + o.group_rects(rect.x, rect.y, rect.w, rect.h) + .into_iter() + .filter(|(_, c)| !c.frame().is_some_and(|(_, _, f)| f.hidden)) + .map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)) + .collect() } /// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in @@ -2651,7 +2659,7 @@ fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32 if p.objects.iter().any(|o| !o.text_boxes().is_empty()) { for (ci, c) in pl.clusters.iter().enumerate() { if let para::ClKind::Object(oi) = c.kind - && let Some(o) = p.objects.get(oi) + && let Some(o) = p.objects.get(oi).filter(|o| !o.frame().is_some_and(|(_, _, f)| f.hidden)) { for id in o.text_boxes() { notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n))); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 14c064a2c..41f9fadf0 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1286,8 +1286,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { obj_index += 1; match p.objects.get(k) { Some(o) if o.is_drawing() => { + // A hidden one takes no room, and with no height its line doesn't draw it. if let Some((w, h, float)) = o.frame() && float.wrap == wordcraft_doc::para::Wrap::Inline + && !float.hidden { // A picture wider than the line keeps its size, as in Word: it runs past // the margin and the page edge clips it (see `ParaEnv::fit_objects`). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a46b5ceda..de4b5d19f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1341,6 +1341,56 @@ fn text_wraps_around_square_float() { assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. }))); } +#[test] +fn hidden_drawings_are_neither_drawn_nor_take_room() { + // A hidden floating shape (VML `visibility:hidden`, `wp:docPr/@hidden`): no wrap area, nothing + // drawn, as in Word for Windows. A hidden inline one takes no room in its line. + let mut d = Document::from_text(&"Words flow around the picture here. ".repeat(30)); + let float = wordcraft_doc::para::Float { + wrap: wordcraft_doc::para::Wrap::Square, + h_rel: wordcraft_doc::para::Anchor::Column, + v_rel: wordcraft_doc::para::Anchor::Paragraph, + hidden: true, + ..Default::default() + }; + let inline = wordcraft_doc::para::Float { hidden: true, ..Default::default() }; + d.insert_object(&Pos::body(0, 0), picture(200.0, 200.0, inline), &Default::default()).unwrap(); + d.insert_object(&Pos::body(0, 0), picture(144.0, 100.0, float), &Default::default()).unwrap(); + let l = lay(&d); + let Placed::Lines { para, .. } = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).unwrap() else { panic!() }; + let first = ¶.lines[0]; + assert!(first.left < 1.0, "the first line uses the full width: {}", first.left); + assert!(first.baseline - first.top < 20.0, "the hidden inline shape doesn't raise the line: {first:?}"); + assert!(!l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. } | Placed::Object { .. }))); + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + assert!(!draws.iter().any(|x| matches!(x, display::Draw::Shape { .. })), "nothing drawn"); + // Showing hidden text doesn't show hidden drawings. + let shown = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden: true, ..Default::default() }); + let Placed::Lines { para, .. } = shown.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).unwrap() else { panic!() }; + assert!(para.lines[0].left < 1.0 && !shown.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. } | Placed::Object { .. }))); + + // A group draws its members but a hidden one. + let mut d = Document::from_text(""); + let member = |hidden: bool, x: f32| wordcraft_doc::para::GroupChild { + x, + y: 0.0, + obj: picture(50.0, 50.0, wordcraft_doc::para::Float { hidden, ..Default::default() }), + }; + let group = InlineObject::Group { + w: 100.0, + h: 50.0, + float: Default::default(), + ch_w: 100.0, + ch_h: 50.0, + children: vec![member(true, 0.0), member(false, 50.0)], + canvas: None, + }; + d.insert_object(&Pos::body(0, 0), group, &Default::default()).unwrap(); + let l = lay(&d); + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + assert_eq!(draws.iter().filter(|x| matches!(x, display::Draw::Shape { .. })).count(), 1, "{draws:?}"); +} + #[test] fn deleted_float_leaves_no_wrap_area_without_markup() { // A square-wrapped shape anchored at the start of the paragraph, its anchor track-deleted. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index caa73c915..0777606f6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (undrawn chart types show a placeholder, #998; previously trivial: saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; 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-12 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: undrawn chart types show a placeholder, #998; previously trivial: saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; 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; @@ -61,10 +61,10 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped | -| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416) | +| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | -| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611) | +| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Not read: vertical text (`layout-flow`) | | Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | | Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | @@ -163,6 +163,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | VML text boxes keep their internal margins (`inset`), fit to their text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`); hidden drawings (`visibility:hidden`, `wp:docPr/@hidden`) are kept but not shown | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | From 2ed77831f9fe518351936bcc2b8a3852a69c19ed Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:01:20 +0200 Subject: [PATCH 022/199] Canvas: middle-click scrolls the document, like Word (#1121) A middle click on the canvas starts scrolling toward the pointer, faster the farther it is from the click, with a dead zone around it. Another click, Escape, any key, the wheel or losing focus stops it; held and dragged, the release stops it. Same gesture and speed curve as pdfcraft's autoscroll; on for Windows and Linux/BSD, off on macOS and the web. Co-authored-by: Brandon Thomas --- crates/ui-egui/src/autoscroll.rs | 195 +++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 99 ++++++++++++++-- crates/ui-egui/src/lib.rs | 1 + 3 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/autoscroll.rs diff --git a/crates/ui-egui/src/autoscroll.rs b/crates/ui-egui/src/autoscroll.rs new file mode 100644 index 000000000..4de5f59b5 --- /dev/null +++ b/crates/ui-egui/src/autoscroll.rs @@ -0,0 +1,195 @@ +//! Middle-click scrolling of the document canvas (#1065), the same gesture as Word on Windows +//! and pdfcraft's viewer: click the wheel and a marker shows where; moving the pointer above or +//! below it scrolls, faster the farther away. Another click, Escape, any other key, the wheel or +//! leaving the window stops it. Holding the wheel down and moving scrolls too, and then letting +//! go stops. +//! +//! WordCraft doesn't paste the primary selection on a middle click, so on Linux the button is +//! free for this. macOS apps don't scroll this way, and browsers have their own middle-click +//! behaviour, so there the button is left alone. + +use egui::{CursorIcon, Event, Key, PointerButton, Pos2, Stroke, Vec2, vec2}; + +/// Where the gesture exists at all (see the module docs). +const SUPPORTED: bool = !cfg!(any(target_os = "macos", target_os = "ios", target_arch = "wasm32")); + +/// Pointer distance (screen points) around the anchor that doesn't scroll. +const DEAD_ZONE: f32 = 15.0; +// Chromium's autoscroll_controller.cc uses distance^2.2 * 0.000008; its +// ui/events/gestures/fixed_velocity_curve.cc multiplies elapsed seconds by 5000. +const SPEED_EXPONENT: f32 = 2.2; +const SPEED_MULTIPLIER: f32 = 0.04; +// Bound hostile coordinates before exponentiation, far beyond ordinary screen distances. +const MAX_DISPLACEMENT: f32 = 1_000_000.0; + +#[derive(Debug)] +pub(crate) struct AutoScroll { + /// Where the wheel was clicked; `Some` while scrolling. + anchor: Option, + /// The press that started it is still down… + holding: bool, + /// …and the pointer has left the dead zone since: letting go ends it. + dragged: bool, + /// Own a stopping click through its release, so it can't also move the caret. + cancel_button: Option, + block_input: bool, + pub(crate) supported: bool, +} + +impl Default for AutoScroll { + fn default() -> Self { + Self { anchor: None, holding: false, dragged: false, cancel_button: None, block_input: false, supported: SUPPORTED } + } +} + +impl AutoScroll { + pub(crate) fn active(&self) -> bool { + self.anchor.is_some() + } + + pub(crate) fn cancel(&mut self) { + self.anchor = None; + self.holding = false; + self.dragged = false; + self.cancel_button = None; + self.block_input = false; + } + + /// Mouse buttons belong to the gesture this frame: the canvas mustn't act on them. + pub(crate) fn blocks_input(&self) -> bool { + self.block_input + } + + /// Run before the canvas scrolls. Returns this frame's delta (positive moves the content + /// down, as the canvas's wheel delta). Starting is restricted to the visible canvas; once + /// started, moving outside it still controls the speed. + pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect) -> Vec2 { + if !self.supported { + self.cancel(); + return Vec2::ZERO; + } + let ctx = ui.ctx(); + let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| { + let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2] + .into_iter() + .find(|button| i.pointer.button_pressed(*button)); + ( + i.pointer.hover_pos(), + // The press event itself: later movement in this frame mustn't move the anchor. + i.events.iter().find_map(|event| match event { + Event::PointerButton { pos, button: PointerButton::Middle, pressed: true, .. } if pos.is_finite() => Some(*pos), + _ => None, + }), + i.pointer.button_down(PointerButton::Middle), + i.pointer.button_released(PointerButton::Middle), + !i.focused + || cancel_button.is_some() + || i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. } | Event::MouseWheel { .. } | Event::Zoom(_))), + cancel_button, + i.stable_dt, + ) + }); + self.block_input = self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released; + if let Some(button) = self.cancel_button { + if !ctx.input(|i| i.pointer.button_down(button)) { + self.cancel_button = None; + } + return Vec2::ZERO; + } + if self.active() && (cancel || pointer.is_none()) { + // Escape only stops scrolling; any other key stops it and does its usual job. + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + self.cancel(); + self.block_input = true; + self.cancel_button = cancel_button; + return Vec2::ZERO; + } + if let Some(pressed_at) = middle_press { + if self.active() { + self.cancel(); + self.block_input = true; + self.cancel_button = Some(PointerButton::Middle); + return Vec2::ZERO; + } else if !cancel && viewport.intersect(ui.clip_rect()).contains(pressed_at) && ctx.layer_id_at(pressed_at) == Some(ui.layer_id()) { + self.anchor = Some(pressed_at); + self.holding = true; + self.dragged = false; + } + } + let (Some(anchor), Some(pointer)) = (self.anchor, pointer) else { return Vec2::ZERO }; + let displacement = pointer.y - anchor.y; + if self.holding { + self.dragged |= displacement.abs() > DEAD_ZONE; + if !middle_down || middle_released { + self.holding = false; + if self.dragged { + self.cancel(); + self.block_input = true; + return Vec2::ZERO; + } + } + } + if displacement.is_finite() && displacement.abs() > DEAD_ZONE { + // Continuous redraws let stable_dt use measured frame time, so the speed doesn't + // depend on the frame rate. + ctx.request_repaint(); + } + vec2(0.0, scroll_delta(displacement, dt)) + } + + /// Draw a marker at the anchor, above the pages, and the up-down cursor. Run after the + /// canvas has set its own cursor. + pub(crate) fn paint(&self, ui: &egui::Ui, viewport: egui::Rect) { + let Some(anchor) = self.anchor else { return }; + let painter = ui.painter().with_clip_rect(viewport); + let ink = ui.visuals().text_color(); + painter.circle(anchor, 13.0, ui.visuals().window_fill(), Stroke::new(1.0, ink)); + painter.circle_filled(anchor, 2.0, ink); + for direction in [-1.0, 1.0] { + painter.add(egui::Shape::convex_polygon( + vec![anchor + vec2(-4.0, direction * 6.0), anchor + vec2(4.0, direction * 6.0), anchor + vec2(0.0, direction * 10.0)], + ink, + Stroke::NONE, + )); + } + ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + } +} + +fn scroll_delta(displacement: f32, dt: f32) -> f32 { + if !displacement.is_finite() || !dt.is_finite() { + return 0.0; + } + let distance = displacement.abs(); + if distance <= DEAD_ZONE { + return 0.0; + } + let speed = distance.min(MAX_DISPLACEMENT).powf(SPEED_EXPONENT) * SPEED_MULTIPLIER; + // Pointer below the anchor scrolls down: the content moves up. A stalled frame (an idle or + // hidden window) mustn't jump pages. + -displacement.signum() * speed * dt.clamp(0.0, 0.05) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speed_has_a_dead_zone_grows_with_distance_and_rejects_invalid_input() { + for y in [-15.0, -1.0, 0.0, 1.0, 15.0] { + assert_eq!(scroll_delta(y, 0.016), 0.0); + } + assert!(scroll_delta(30.0, 0.016) < 0.0, "below the anchor scrolls down"); + assert_eq!(scroll_delta(30.0, 0.016), -scroll_delta(-30.0, 0.016)); + for (near, far) in [(16.0, 30.0), (30.0, 100.0), (100.0, 200.0)] { + assert!(scroll_delta(far, 0.016).abs() > scroll_delta(near, 0.016).abs()); + } + assert_eq!(scroll_delta(1000.0, 10.0), scroll_delta(1000.0, 0.05)); + assert_eq!(scroll_delta(1000.0, -1.0), 0.0); + assert!(scroll_delta(f32::MAX, 0.016).is_finite()); + for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(scroll_delta(invalid, 0.016), 0.0); + assert_eq!(scroll_delta(1000.0, invalid), 0.0); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 44a32ef9d..7a6369096 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -98,6 +98,8 @@ pub struct CanvasState { pub(crate) ink_ui: crate::ink::InkUi, /// Wheel/touchpad scrolling (smooth notches, touchpad momentum). pub(crate) wheel: crate::scroll::CanvasScroll, + /// Middle-click scrolling (#1065). + pub(crate) auto_scroll: crate::autoscroll::AutoScroll, /// The scroll offset and its maximum at the end of last frame. pub(crate) scroll_offset: egui::Vec2, scroll_max: egui::Vec2, @@ -189,6 +191,7 @@ impl Default for CanvasState { pasted: false, swallow_text: false, wheel: Default::default(), + auto_scroll: Default::default(), scroll_offset: egui::Vec2::ZERO, scroll_max: egui::Vec2::ZERO, scroll_request: None, @@ -867,7 +870,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } else { egui::Vec2::ZERO }; - let delta = wheel_delta + auto_delta; + // Middle-click scrolling (#1065): Print Layout and the other editing views, not Read Mode. + let middle_delta = if app.session.view.read_mode || app.dialog.is_some() { + app.canvas.auto_scroll.cancel(); + egui::Vec2::ZERO + } else { + app.canvas.auto_scroll.update(ui, area) + }; + let delta = wheel_delta + auto_delta + middle_delta; let mut scroll_area = egui::ScrollArea::both() .id_salt("canvas_scroll") .auto_shrink([false, false]) @@ -1236,6 +1246,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.focused = resp.has_focus(); // Screen readers: the document under the canvas, with the caret (#488). crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area); + // The buttons that start or stop middle-click scrolling don't also click the page. + let middle_gesture = app.canvas.auto_scroll.blocks_input(); // Double-click the space between two pages (or the line between them) to hide or show it. if resp.double_clicked() && app.session.view.mode == wordcraft_layout::ViewMode::Print @@ -1246,14 +1258,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space})); } else // Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing. - if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) { + if !middle_gesture && !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) { mouse(app, ui, &resp, &rects, &layout, geo.scale); } // A click on a check box ticks it; on a list's button opens the list. - crate::content_controls::clicks(app, &resp); + if !middle_gesture { + crate::content_controls::clicks(app, &resp); + } + let secondary_clicked = resp.secondary_clicked() && !middle_gesture; // Selected table cells: a right-click off them drops them, even between the selection's ends // (beside a selected column), so the menu acts on the cell clicked. - if resp.secondary_clicked() + if secondary_clicked && let Some(range) = app.session.cell_range() && let Some(p) = resp.interact_pointer_pos() && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) @@ -1267,7 +1282,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } // Right-click: move the caret there (unless inside the selection), then the context menu. // Right-click in an equation puts the caret there and opens the equation menu. - if resp.secondary_clicked() + if secondary_clicked && let Some(p) = resp.interact_pointer_pos() && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) { @@ -1283,7 +1298,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } // Right-click on a picture, shape or text box selects it (its menu offers Wrap Text). - if resp.secondary_clicked() + if secondary_clicked && app.session.math.is_none() && let Some(p) = resp.interact_pointer_pos() && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) @@ -1304,10 +1319,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if from_keys { context_suggestions(app); app.canvas.context_menu_at = caret_screen(app).map(|(p, h)| p + egui::vec2(0.0, h)); - } else if resp.secondary_clicked() { + } else if secondary_clicked { app.canvas.context_menu_at = resp.interact_pointer_pos(); } - let open = if resp.secondary_clicked() || from_keys { + let open = if secondary_clicked || from_keys { Some(egui::SetOpenCommand::Bool(true)) } else if resp.clicked() { Some(egui::SetOpenCommand::Bool(false)) @@ -1339,6 +1354,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::table_resize::cursor(app, ui, &resp, &layout, &rects, geo.scale); crate::table_mouse::cursor(app, ui, &resp, &layout, &rects, geo.scale); crate::table_pen::cursor(app, ui, &resp); + app.canvas.auto_scroll.paint(ui, area); let _ = origin; if let (Some(h), Some(v)) = (hruler, vruler) { rulers(app, ui, h, v, &rects, &layout, geo.scale); @@ -3108,6 +3124,73 @@ mod tests { assert_eq!(clamped_scroll(egui::Vec2::ZERO, vec2(0.0, 50.0), egui::Vec2::ZERO).y, 0.0, "nothing to scroll"); } + /// #1065: a middle click starts scrolling, the pointer below the click scrolls down on its + /// own frame after frame, and a second middle click stops it; a primary click stops it without + /// moving the caret. Held down and dragged, letting go stops it. + #[test] + fn middle_click_scrolls_toward_the_pointer_until_clicked_again() { + let text = vec!["A line of text in a long document."; 400].join("\n"); + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text(&text)), Default::default()); + a.canvas.auto_scroll.supported = true; + let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(6); + let anchor = h.state().canvas.viewport.expect("the canvas is shown").center(); + let button = |h: &mut H, at: Pos2, button, pressed| { + h.event(egui::Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }); + h.step(); + }; + let offset = |h: &H| h.state().canvas.scroll_offset.y; + let below = anchor + vec2(0.0, 150.0); + + h.event(egui::Event::PointerMoved(anchor)); + button(&mut h, anchor, egui::PointerButton::Middle, true); + button(&mut h, anchor, egui::PointerButton::Middle, false); + h.run_steps(3); + assert!(h.state().canvas.auto_scroll.active()); + assert_eq!(offset(&h), 0.0, "the pointer at the click doesn't scroll"); + h.event(egui::Event::PointerMoved(below)); + h.run_steps(10); + let scrolled = offset(&h); + assert!(scrolled > 50.0, "the pointer below the click scrolls down: {scrolled}"); + button(&mut h, below, egui::PointerButton::Middle, true); + button(&mut h, below, egui::PointerButton::Middle, false); + let stopped = offset(&h); + h.run_steps(10); + assert!(!h.state().canvas.auto_scroll.active()); + assert_eq!(offset(&h), stopped, "a second click stops it"); + + // A primary click stops it too, and isn't a click on the page. + let caret = h.state().session.sel.clone(); + button(&mut h, anchor, egui::PointerButton::Middle, true); + button(&mut h, anchor, egui::PointerButton::Middle, false); + assert!(h.state().canvas.auto_scroll.active()); + button(&mut h, anchor, egui::PointerButton::Primary, true); + button(&mut h, anchor, egui::PointerButton::Primary, false); + h.run_steps(2); + assert!(!h.state().canvas.auto_scroll.active()); + assert_eq!(h.state().session.sel, caret, "the stopping click leaves the caret"); + + // Held down and dragged: scrolls while held, stops on release. + h.event(egui::Event::PointerMoved(anchor)); + button(&mut h, anchor, egui::PointerButton::Middle, true); + h.event(egui::Event::PointerMoved(below)); + h.run_steps(5); + let held = offset(&h); + assert!(held > stopped, "scrolls while the wheel is held"); + button(&mut h, below, egui::PointerButton::Middle, false); + let released = offset(&h); + h.run_steps(10); + assert!(!h.state().canvas.auto_scroll.active()); + assert_eq!(offset(&h), released, "letting go after a drag stops it"); + } + /// A right-click at `at`, the button pressed and released a frame apart. fn right_click(h: &mut H, at: Pos2) { h.event(egui::Event::PointerMoved(at)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 98084665d..15dee0aaa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ macro_rules! tl { pub mod a11y; pub mod a11y_pane; +pub mod autoscroll; pub mod backstage; pub mod canvas; pub mod caret_glide; From 99fa307cf2c3bad494246dd7c30c872ba39a0e9c Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:01:37 +0200 Subject: [PATCH 023/199] Honour Remove personal information / date and time on save (#1062) (#1131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #1062: honour Remove personal information / Remove date and time on save w:removePersonalInformation and w:removeDateAndTime (ECMA-376 §17.15.1.69-70) are read into Settings, written back in CT_Settings order, and honoured by the DOCX writer: comment and revision authors saved as "Author" (initials "A"), no dc:creator / cp:lastModifiedBy, no w:date on comments and tracked changes. The open document keeps its real values. New file.privacyOptions command sets both. Co-Authored-By: Claude Opus 5.5 * #1062: reading privacy options has no side effects; File › Options › Save check boxes file.privacy reads the settings (a pure command: no undo step, no unsaved changes); file.privacyOptions only changes them and refuses an empty call. File › Options › Save gets a "Privacy for this document" section with the two check boxes, translated in every catalog. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 +- crates/doc/src/lib.rs | 11 +++ crates/docx/src/read/mod.rs | 2 + crates/docx/src/write/mod.rs | 39 +++++++-- crates/docx/src/write/story.rs | 5 +- crates/docx/tests/privacy.rs | 127 ++++++++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 27 ++++++ crates/engine/src/tests.rs | 50 +++++++++++ crates/ui-egui/src/i18n/bg.tsv | 7 ++ crates/ui-egui/src/i18n/cs.tsv | 7 ++ crates/ui-egui/src/i18n/de.tsv | 7 ++ crates/ui-egui/src/i18n/es.tsv | 7 ++ crates/ui-egui/src/i18n/et.tsv | 7 ++ crates/ui-egui/src/i18n/fr.tsv | 7 ++ crates/ui-egui/src/i18n/hu.tsv | 7 ++ crates/ui-egui/src/i18n/it.tsv | 7 ++ crates/ui-egui/src/i18n/ja.tsv | 7 ++ crates/ui-egui/src/i18n/pl.tsv | 7 ++ crates/ui-egui/src/i18n/pt-br.tsv | 7 ++ crates/ui-egui/src/i18n/ru.tsv | 7 ++ crates/ui-egui/src/i18n/sr-latn.tsv | 7 ++ crates/ui-egui/src/i18n/sr.tsv | 7 ++ crates/ui-egui/src/i18n/tr.tsv | 7 ++ crates/ui-egui/src/i18n/uk.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 ++ crates/ui-egui/src/options.rs | 46 ++++++++++ docs/file-format-parity.md | 5 +- 28 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 crates/docx/tests/privacy.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4a9add834..015905e64 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-12 · **Change:** trivial (sample-text formulas on Enter, #1067; previously trivial: direct run-level native InkML content parts survive DOCX saves; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: sample-text formulas on Enter, #1067; previously trivial: direct run-level native InkML content parts survive DOCX saves; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | +| 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both | | 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo | | 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet | | 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d6f372de1..c785c7ed7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -480,6 +480,15 @@ pub struct Settings { pub attached_template: Option, /// Update the document's styles from the attached template when it is opened (`w:linkStyles`). pub link_styles: bool, + /// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376 + /// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out + /// the document's creator and last-modified-by. The open document keeps its real values. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub remove_personal_info: bool, + /// Remove date and time from annotations when saving (`w:removeDateAndTime`, §17.15.1.69): + /// comments and tracked changes are saved without `w:date`. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub remove_date_time: bool, } /// Word's compatibility mode for documents that don't state one. @@ -557,6 +566,8 @@ impl Default for Settings { bib_style: None, attached_template: None, link_styles: false, + remove_personal_info: false, + remove_date_time: false, } } } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 5f16f8236..9d9499066 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -994,6 +994,8 @@ impl Reader<'_> { .filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH); } "w:linkStyles" => s.link_styles = on_off(k), + "w:removePersonalInformation" => s.remove_personal_info = on_off(k), + "w:removeDateAndTime" => s.remove_date_time = on_off(k), "w:compat" => { // Only Word's own setting: another `w:uri` names a different application's. let mode = k diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index bb6fb98bb..81f9005f9 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -293,12 +293,14 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); w.open("w:settings", &ns_refs); w.empty("w:zoom", &[("w:percent", "100")]); + // §17.15.1.70 / §17.15.1.69, right after `w:zoom` in CT_Settings. + if s.remove_personal_info { + w.empty("w:removePersonalInformation", &[]); + } + if s.remove_date_time { + w.empty("w:removeDateAndTime", &[]); + } if s.page_color.is_some() { w.empty("w:displayBackgroundShape", &[]); } @@ -1029,8 +1038,28 @@ fn theme_xml(doc: &Document) -> Vec { w.into_bytes() } +/// The author the saved file gives a comment or tracked change when the document removes personal +/// information on save (`w:removePersonalInformation`), as Word does. +pub(crate) const ANONYMOUS_AUTHOR: &str = "Author"; +const ANONYMOUS_INITIALS: &str = "A"; + +/// The `w:author` written for an annotation by `author`: "Author" when the document removes +/// personal information on save, "Unknown" when it has none. +pub(crate) fn annotation_author<'a>(doc: &Document, author: &'a str) -> &'a str { + if doc.settings.remove_personal_info { + ANONYMOUS_AUTHOR + } else if author.is_empty() { + "Unknown" + } else { + author + } +} + fn core_xml(doc: &Document) -> Vec { let c = &doc.core; + // Remove personal information (§17.15.1.70): no creator or last-modified-by in the file. + let personal = |v: &String| if doc.settings.remove_personal_info { String::new() } else { v.clone() }; + let (creator, last_modified_by) = (personal(&c.creator), personal(&c.last_modified_by)); let mut w = W::new(); w.open( "cp:coreProperties", @@ -1045,10 +1074,10 @@ fn core_xml(doc: &Document) -> Vec { for (tag, v) in [ ("dc:title", &c.title), ("dc:subject", &c.subject), - ("dc:creator", &c.creator), + ("dc:creator", &creator), ("cp:keywords", &c.keywords), ("dc:description", &c.description), - ("cp:lastModifiedBy", &c.last_modified_by), + ("cp:lastModifiedBy", &last_modified_by), ("cp:category", &c.category), ] { if !v.is_empty() { diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index d894f6715..1c01e4a5a 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -369,8 +369,9 @@ impl Writer<'_> { fn rev_attrs(&mut self, idx: u32) -> (String, String, String) { self.next_rev_id += 1; let r = self.doc.revisions.get(idx as usize); - let author = r.map(|r| r.author.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| "Unknown".into()); - let date = r.map(|r| r.date.clone()).unwrap_or_default(); + let author = super::annotation_author(self.doc, r.map_or("", |r| r.author.as_str())).to_string(); + // Remove date and time (§17.15.1.69): tracked changes are saved without `w:date`. + let date = if self.doc.settings.remove_date_time { String::new() } else { r.map(|r| r.date.clone()).unwrap_or_default() }; (self.next_rev_id.to_string(), author, date) } diff --git a/crates/docx/tests/privacy.rs b/crates/docx/tests/privacy.rs new file mode 100644 index 000000000..cb345ef0d --- /dev/null +++ b/crates/docx/tests/privacy.rs @@ -0,0 +1,127 @@ +//! Privacy settings (#1062): `w:removePersonalInformation` and `w:removeDateAndTime` +//! (ECMA-376 Part 1 §17.15.1.69–70) are kept on save and honoured by the writer. Synthetic +//! fixtures written for these tests from the spec; the names are made up. +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; + +const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; +const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; + +fn zip(entries: &[(&str, String)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, text) in entries { + z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(text.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn unzip(bytes: &[u8]) -> BTreeMap { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut parts = BTreeMap::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let mut s = String::new(); + if f.read_to_string(&mut s).is_ok() { + parts.insert(f.name().to_string(), s); + } + } + parts +} + +/// A package with two named commenters, a tracked insertion and deletion, the document +/// properties' creator and last-modified-by, and `settings` inside `w:settings`. +fn fixture(settings: &str) -> Vec { + let body = r#"Clause one. +AddedRemoved"#; + let comments = format!( + r#"Check this.Agreed."# + ); + let core = r#"Draft termsQuill RamseyOna Vettel"#; + let root_rels = format!( + r#""# + ); + let doc_rels = format!( + r#""# + ); + zip(&[ + ("_rels/.rels", root_rels), + ("word/document.xml", format!(r#"{body}"#)), + ("word/_rels/document.xml.rels", doc_rels), + ("word/comments.xml", comments), + ("word/settings.xml", format!(r#"{settings}"#)), + ("docProps/core.xml", core.to_string()), + ]) +} + +const NAMES: [&str; 4] = ["Quill Ramsey", "Ona Vettel", "\"QR\"", "\"OV\""]; + +/// Every part of the saved package, joined. +fn saved(doc: &wordcraft_doc::Document) -> (BTreeMap, String) { + let parts = unzip(&wordcraft_docx::write(doc).unwrap()); + let all = parts.values().cloned().collect::>().join("\n"); + (parts, all) +} + +#[test] +fn remove_personal_information_and_dates_on_save() { + let doc = wordcraft_docx::read(&fixture("")).unwrap(); + assert!(doc.settings.remove_personal_info && doc.settings.remove_date_time); + let before = doc.clone(); + let (parts, all) = saved(&doc); + // The open document keeps its real names and dates. + assert_eq!(doc, before); + assert_eq!(doc.core.creator, "Quill Ramsey"); + assert!(doc.comments.values().any(|c| c.author == "Ona Vettel" && !c.date.is_empty())); + // No name, initials or annotation date anywhere in the file. + for name in NAMES { + assert!(!all.contains(name), "{name} saved"); + } + assert!(!all.contains("w:date="), "annotation date saved"); + assert!(!all.contains("2026-03-0"), "annotation date saved"); + let comments = &parts["word/comments.xml"]; + assert_eq!(comments.matches(r#"w:author="Author""#).count(), 2, "{comments}"); + assert_eq!(comments.matches(r#"w:initials="A""#).count(), 2, "{comments}"); + let body = &parts["word/document.xml"]; + assert!(body.contains("").unwrap(); + let dates = settings.find("").unwrap(); + assert!(zoom < personal && personal < dates, "{settings}"); + let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + assert!(again.settings.remove_personal_info && again.settings.remove_date_time); +} + +#[test] +fn each_privacy_setting_works_alone() { + // Dates only: names stay. + let doc = wordcraft_docx::read(&fixture("")).unwrap(); + let (parts, all) = saved(&doc); + assert!(!all.contains("w:date=")); + assert!(all.contains("Quill Ramsey") && all.contains("Ona Vettel")); + assert!(parts["docProps/core.xml"].contains("Quill Ramsey")); + assert!(!parts["word/settings.xml"].contains("removePersonalInformation")); + // Names only: dates stay. + let doc = wordcraft_docx::read(&fixture(r#""#)).unwrap(); + let (_, all) = saved(&doc); + for name in NAMES { + assert!(!all.contains(name), "{name} saved"); + } + assert_eq!(all.matches("w:date=").count(), 4, "two comments and two changes keep their dates"); + // Off (explicitly or absent): everything is saved as it was. + for settings in ["", r#""#] { + let doc = wordcraft_docx::read(&fixture(settings)).unwrap(); + assert!(!doc.settings.remove_personal_info && !doc.settings.remove_date_time); + let (parts, all) = saved(&doc); + assert!(all.contains("Quill Ramsey") && all.contains(r#"w:initials="OV""#)); + assert_eq!(all.matches("w:date=").count(), 4); + assert!(parts["docProps/core.xml"].contains("Ona Vettel")); + assert!(!parts["word/settings.xml"].contains("removeDateAndTime")); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0ebf1a3d9..4ca72abe8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -90,6 +90,28 @@ pub fn specs() -> Vec { CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author) .params(r#"{"name": string (author of new tracked changes and comments)}"#) .pure(), + // Trust Center › Privacy Options: the document's `w:removePersonalInformation` and + // `w:removeDateAndTime` settings, honoured when it is saved as .docx (#1062). + // Changing them is a document change (undoable, refused on a protected document); reading + // them is `file.privacy`, which changes nothing. + CommandSpec::new("file.privacyOptions", "Privacy Options", "File › Options › Save", |s, v| { + let personal = p::bool(v, "removePersonalInfo"); + let date_time = p::bool(v, "removeDateTime"); + if personal.is_none() && date_time.is_none() { + return Err(CmdError::Params("give removePersonalInfo or removeDateTime (file.privacy reads them)".into())); + } + if let Some(on) = personal { + s.doc.settings.remove_personal_info = on; + } + if let Some(on) = date_time { + s.doc.settings.remove_date_time = on; + } + Ok(privacy(s)) + }) + .params(r#"{"removePersonalInfo"?: bool (save comment and revision authors as "Author", no creator or last-modified-by), "removeDateTime"?: bool (save comments and tracked changes without dates)} — at least one; the open document keeps its real names and dates"#), + CommandSpec::new("file.privacy", "Privacy Settings", "File › Options › Save", |s, _| Ok(privacy(s))) + .params(r#"{} → {"removePersonalInfo": bool, "removeDateTime": bool} (file.privacyOptions changes them)"#) + .pure(), CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(), CommandSpec::new("document.text", "Document Text", "Agents", |s, v| { let story = super::story_param(s, v); @@ -118,6 +140,11 @@ pub fn specs() -> Vec { ] } +/// The document's privacy settings, as `file.privacy` and `file.privacyOptions` return them. +fn privacy(s: &Session) -> Value { + json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time}) +} + fn new(s: &mut Session, v: &Value) -> CmdResult { let doc = match p::str(v, "template").unwrap_or("blank") { "sample" => crate::sample::sample_document(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d69108b12..e0b71323c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1600,6 +1600,56 @@ fn set_author_names_tracked_changes_headlessly() { assert!(s.doc.comments.values().all(|c| c.author == "Claude (copyedit)" && c.initials == "C(")); } +#[test] +fn privacy_options_strip_names_and_dates_from_the_saved_docx_only() { + // Issue #1062: Remove personal information / date and time apply to the saved file. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "original"})); + run(&mut s, "file.setAuthor", json!({"name": "Mira Calloway"})); + run(&mut s, "file.properties", json!({"author": "Mira Calloway"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.insert", json!({"text": " added"})); + run(&mut s, "select.text", json!({"text": "added"})); + run(&mut s, "review.newComment", json!({"text": "Tightened."})); + let off = run(&mut s, "file.privacy", json!({})); + assert_eq!(off, json!({"removePersonalInfo": false, "removeDateTime": false})); + let plain = wordcraft_docx::write(&s.doc).unwrap(); + let back = wordcraft_docx::read(&plain).unwrap(); + assert!(back.comments.values().all(|c| c.author == "Mira Calloway" && !c.date.is_empty())); + let on = run(&mut s, "file.privacyOptions", json!({"removePersonalInfo": true, "removeDateTime": true})); + assert_eq!(on, json!({"removePersonalInfo": true, "removeDateTime": true})); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert!(back.settings.remove_personal_info && back.settings.remove_date_time); + assert!(back.comments.values().all(|c| c.author == "Author" && c.date.is_empty()), "{:?}", back.comments); + assert!(!back.revisions.is_empty()); + assert!(back.revisions.iter().all(|r| r.author == "Author" && r.date.is_empty()), "{:?}", back.revisions); + assert!(back.core.creator.is_empty() && back.core.last_modified_by.is_empty()); + // The open document keeps the real names and dates. + assert!(s.doc.comments.values().all(|c| c.author == "Mira Calloway" && !c.date.is_empty())); + assert!(s.doc.revisions.iter().any(|r| r.author == "Mira Calloway")); + assert_eq!(s.doc.core.creator, "Mira Calloway"); + // Undoable like any document change. + run(&mut s, "edit.undo", json!({})); + assert!(!s.doc.settings.remove_personal_info); +} + +/// Reading the privacy settings changes nothing: no undo step, no unsaved changes, and it works +/// on a protected document. `file.privacyOptions` without a setting is refused, not a silent edit. +#[test] +fn reading_privacy_options_is_side_effect_free() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Draft"})); + s.dirty = false; + let undo = s.undo_depth(); + assert_eq!(run(&mut s, "file.privacy", json!({})), json!({"removePersonalInfo": false, "removeDateTime": false})); + assert!(s.run("file.privacyOptions", &json!({})).is_err()); + assert!(s.run("file.privacyOptions", &json!({"removePersonalInfo": "yes"})).is_err()); + assert_eq!(s.undo_depth(), undo, "no undo step"); + assert!(!s.dirty, "no unsaved changes"); + s.doc.settings.protection = Some("readOnly".into()); + assert_eq!(run(&mut s, "file.privacy", json!({}))["removeDateTime"], false); +} + #[test] fn set_author_refuses_blank_names_and_clamps_long_ones() { let mut s = s(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 7e0a9c234..f25bc3d9f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2465,3 +2465,10 @@ Table Properties… Свойства на таблица… Search Търсене Title Bar Заглавна лента + Privacy Options Настройки за поверителност + Trust Center Център за сигурност + Privacy for this document Поверителност на този документ + Remove my name from comments, changes and file properties when saving Премахване на името ми от коментарите, промените и свойствата на файла при запазване + Leave out the date and time of comments and tracked changes when saving Без дата и час на коментарите и проследените промени при запазване + The document you are editing keeps the names and dates; only the saved file leaves them out. Документът, който редактирате, запазва имената и датите; само записаният файл ги пропуска. + Privacy Settings Текущи настройки за поверителност diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 553e8d224..ce4c107a5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2081,3 +2081,10 @@ Automatic ({region}) Automaticky ({region}) Search Hledat Title Bar Záhlaví okna + Privacy Options Možnosti ochrany osobních údajů + Trust Center Centrum zabezpečení + Privacy for this document Soukromí tohoto dokumentu + Remove my name from comments, changes and file properties when saving Při ukládání odebrat mé jméno z komentářů, změn a vlastností souboru + Leave out the date and time of comments and tracked changes when saving Při ukládání vynechat datum a čas komentářů a sledovaných změn + The document you are editing keeps the names and dates; only the saved file leaves them out. Upravovaný dokument si jména a data ponechá; vynechá je jen uložený soubor. + Privacy Settings Aktuální nastavení soukromí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3f085de06..4bcb12199 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2075,3 +2075,10 @@ Automatic ({region}) Automatisch ({region}) Search Suchen Title Bar Titelleiste + Privacy Options Datenschutzoptionen + Trust Center Sicherheitscenter + Privacy for this document Datenschutz für dieses Dokument + Remove my name from comments, changes and file properties when saving Beim Speichern meinen Namen aus Kommentaren, Änderungen und Dateieigenschaften entfernen + Leave out the date and time of comments and tracked changes when saving Beim Speichern Datum und Uhrzeit von Kommentaren und nachverfolgten Änderungen weglassen + The document you are editing keeps the names and dates; only the saved file leaves them out. Das bearbeitete Dokument behält Namen und Daten; nur die gespeicherte Datei lässt sie weg. + Privacy Settings Aktuelle Datenschutzeinstellungen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 31ee6dedd..dfb2850c8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2442,3 +2442,10 @@ preview Title Título Start-up Inicio de la aplicación Show the Start screen when WordCraft starts Mostrar la pantalla de inicio al abrir WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Activado: WordCraft se abre en Archivo › Inicio, con un documento en blanco, las plantillas y tus documentos recientes. Desactivado: abre un documento en blanco. + Privacy Options Opciones de privacidad + Trust Center Centro de confianza + Privacy for this document Privacidad de este documento + Remove my name from comments, changes and file properties when saving Quitar mi nombre de los comentarios, los cambios y las propiedades del archivo al guardar + Leave out the date and time of comments and tracked changes when saving Omitir la fecha y la hora de los comentarios y los cambios controlados al guardar + The document you are editing keeps the names and dates; only the saved file leaves them out. El documento que editas conserva los nombres y las fechas; solo el archivo guardado los omite. + Privacy Settings Configuración de privacidad actual diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 57893526c..c5426c901 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2110,3 +2110,10 @@ Start-up Käivitamine Show the Start screen when WordCraft starts Näita WordCrafti käivitamisel avakuva On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Sees: WordCraft avaneb vaates Fail › Avaleht, kus on tühi dokument, mallid ja hiljutised dokumendid. Väljas: avaneb tühi dokument. + Privacy Options Privaatsussuvandid + Trust Center Usalduskeskus + Privacy for this document Selle dokumendi privaatsus + Remove my name from comments, changes and file properties when saving Eemalda salvestamisel minu nimi kommentaaridest, muudatustest ja faili atribuutidest + Leave out the date and time of comments and tracked changes when saving Jäta salvestamisel välja kommentaaride ja jälitatud muudatuste kuupäev ja kellaaeg + The document you are editing keeps the names and dates; only the saved file leaves them out. Redigeeritav dokument säilitab nimed ja kuupäevad; need jäetakse välja ainult salvestatud failist. + Privacy Settings Praegused privaatsussätted diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cee1550f9..2d983c7ae 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2084,3 +2084,10 @@ Table Properties… Propriétés du tableau… Search Rechercher Title Bar Barre de titre + Privacy Options Options de confidentialité + Trust Center Centre de gestion de la confidentialité + Privacy for this document Confidentialité de ce document + Remove my name from comments, changes and file properties when saving Supprimer mon nom des commentaires, des modifications et des propriétés du fichier à l’enregistrement + Leave out the date and time of comments and tracked changes when saving Omettre la date et l’heure des commentaires et des modifications suivies à l’enregistrement + The document you are editing keeps the names and dates; only the saved file leaves them out. Le document en cours de modification garde les noms et les dates ; seul le fichier enregistré les omet. + Privacy Settings Paramètres de confidentialité actuels diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8a291fc5f..fc15e281a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2474,3 +2474,10 @@ Table Properties… Táblázat tulajdonságai… Search Keresés Title Bar Címsor + Privacy Options Adatvédelmi beállítások + Trust Center Adatvédelmi központ + Privacy for this document A dokumentum adatvédelme + Remove my name from comments, changes and file properties when saving Mentéskor a nevem eltávolítása a megjegyzésekből, a módosításokból és a fájl tulajdonságaiból + Leave out the date and time of comments and tracked changes when saving Mentéskor a megjegyzések és a követett módosítások dátumának és idejének elhagyása + The document you are editing keeps the names and dates; only the saved file leaves them out. A szerkesztett dokumentum megtartja a neveket és a dátumokat; csak a mentett fájlból maradnak ki. + Privacy Settings Jelenlegi adatvédelmi beállítások diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c840e0ca9..fa8a43459 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2460,3 +2460,10 @@ preview Title Esempio Automatic ({region}) Automatico ({region}) Search Cerca Title Bar Barra del titolo + Privacy Options Opzioni di privacy + Trust Center Centro protezione + Privacy for this document Privacy di questo documento + Remove my name from comments, changes and file properties when saving Rimuovi il mio nome da commenti, modifiche e proprietà del file al salvataggio + Leave out the date and time of comments and tracked changes when saving Ometti data e ora di commenti e revisioni al salvataggio + The document you are editing keeps the names and dates; only the saved file leaves them out. Il documento che stai modificando conserva nomi e date; solo il file salvato li omette. + Privacy Settings Impostazioni di privacy attuali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b9602c39a..98941975e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2076,3 +2076,10 @@ Start-up 起動 Show the Start screen when WordCraft starts WordCraft の起動時にスタート画面を表示する On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. オン: WordCraft は [ファイル] › [ホーム] で開き、白紙の文書、テンプレート、最近使った文書を表示します。オフ: 白紙の文書を開きます。 + Privacy Options プライバシー オプション + Trust Center セキュリティ センター + Privacy for this document このドキュメントのプライバシー + Remove my name from comments, changes and file properties when saving 保存時にコメント、変更、ファイルのプロパティから自分の名前を削除する + Leave out the date and time of comments and tracked changes when saving 保存時にコメントと変更履歴の日時を含めない + The document you are editing keeps the names and dates; only the saved file leaves them out. 編集中のドキュメントには名前と日時が残ります。保存したファイルにだけ含まれません。 + Privacy Settings 現在のプライバシー設定 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 39feff3d9..c07ae73a6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2469,3 +2469,10 @@ preview Title Tytuł Typing Pisanie Search Wyszukaj Title Bar Pasek tytułu + Privacy Options Opcje prywatności + Trust Center Centrum zaufania + Privacy for this document Prywatność tego dokumentu + Remove my name from comments, changes and file properties when saving Usuń moje imię i nazwisko z komentarzy, zmian i właściwości pliku przy zapisywaniu + Leave out the date and time of comments and tracked changes when saving Pomiń datę i godzinę komentarzy i śledzonych zmian przy zapisywaniu + The document you are editing keeps the names and dates; only the saved file leaves them out. Edytowany dokument zachowuje nazwiska i daty; pomija je tylko zapisany plik. + Privacy Settings Bieżące ustawienia prywatności diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 64f18edea..3b7424701 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2088,3 +2088,10 @@ Start-up Inicialização Show the Start screen when WordCraft starts Mostrar a tela inicial ao abrir o WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Ativado: o WordCraft abre em Arquivo › Início, com um documento em branco, os modelos e seus documentos recentes. Desativado: abre um documento em branco. + Privacy Options Opções de privacidade + Trust Center Central de confiabilidade + Privacy for this document Privacidade deste documento + Remove my name from comments, changes and file properties when saving Remover meu nome dos comentários, alterações e propriedades do arquivo ao salvar + Leave out the date and time of comments and tracked changes when saving Omitir a data e a hora dos comentários e das alterações controladas ao salvar + The document you are editing keeps the names and dates; only the saved file leaves them out. O documento que você está editando mantém os nomes e as datas; só o arquivo salvo os omite. + Privacy Settings Configurações de privacidade atuais diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0c9e77990..bd65ce210 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2475,3 +2475,10 @@ preview Title Пример Level 7 Уровень 7 Level 8 Уровень 8 Level 9 Уровень 9 + Privacy Options Параметры конфиденциальности + Trust Center Центр управления безопасностью + Privacy for this document Конфиденциальность этого документа + Remove my name from comments, changes and file properties when saving Удалять моё имя из примечаний, изменений и свойств файла при сохранении + Leave out the date and time of comments and tracked changes when saving Не сохранять дату и время примечаний и отслеживаемых изменений + The document you are editing keeps the names and dates; only the saved file leaves them out. Редактируемый документ сохраняет имена и даты; их не будет только в сохранённом файле. + Privacy Settings Текущие параметры конфиденциальности diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 26652040d..4e34126c6 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2086,3 +2086,10 @@ Start-up Pokretanje Show the Start screen when WordCraft starts Prikaži početni ekran pri pokretanju programa WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Uključeno: WordCraft se otvara na Fajl › Početak, sa praznim dokumentom, šablonima i nedavnim dokumentima. Isključeno: otvara prazan dokument. + Privacy Options Opcije privatnosti + Trust Center Centar za bezbednost + Privacy for this document Privatnost ovog dokumenta + Remove my name from comments, changes and file properties when saving Ukloni moje ime iz komentara, izmena i svojstava datoteke pri čuvanju + Leave out the date and time of comments and tracked changes when saving Izostavi datum i vreme komentara i praćenih izmena pri čuvanju + The document you are editing keeps the names and dates; only the saved file leaves them out. Dokument koji uređujete zadržava imena i datume; izostavlja ih samo sačuvana datoteka. + Privacy Settings Trenutna podešavanja privatnosti diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 38c925863..f48404694 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2090,3 +2090,10 @@ Start-up Покретање Show the Start screen when WordCraft starts Прикажи почетни екран при покретању програма WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Укључено: WordCraft се отвара на Фајл › Почетак, са празним документом, шаблонима и недавним документима. Искључено: отвара празан документ. + Privacy Options Опције приватности + Trust Center Центар за безбедност + Privacy for this document Приватност овог документа + Remove my name from comments, changes and file properties when saving Уклони моје име из коментара, измена и својстава датотеке при чувању + Leave out the date and time of comments and tracked changes when saving Изостави датум и време коментара и праћених измена при чувању + The document you are editing keeps the names and dates; only the saved file leaves them out. Документ који уређујете задржава имена и датуме; изоставља их само сачувана датотека. + Privacy Settings Тренутна подешавања приватности diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1f299b8eb..aa88c064c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2474,3 +2474,10 @@ preview Title Örnek Level 7 Düzey 7 Level 8 Düzey 8 Level 9 Düzey 9 + Privacy Options Gizlilik Seçenekleri + Trust Center Güven Merkezi + Privacy for this document Bu belgenin gizliliği + Remove my name from comments, changes and file properties when saving Kaydederken adımı açıklamalardan, değişikliklerden ve dosya özelliklerinden kaldır + Leave out the date and time of comments and tracked changes when saving Kaydederken açıklamaların ve izlenen değişikliklerin tarih ve saatini dahil etme + The document you are editing keeps the names and dates; only the saved file leaves them out. Düzenlediğiniz belge adları ve tarihleri korur; yalnızca kaydedilen dosya bunları içermez. + Privacy Settings Geçerli gizlilik ayarları diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cd0204b5e..1715604e8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2088,3 +2088,10 @@ Start-up Запуск Show the Start screen when WordCraft starts Показувати початковий екран під час запуску WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Увімкнено: WordCraft відкривається на сторінці Файл › Основне з порожнім документом, шаблонами та нещодавніми документами. Вимкнено: відкривається порожній документ. + Privacy Options Параметри конфіденційності + Trust Center Центр безпеки + Privacy for this document Конфіденційність цього документа + Remove my name from comments, changes and file properties when saving Вилучати моє ім’я з приміток, змін і властивостей файлу під час збереження + Leave out the date and time of comments and tracked changes when saving Не зберігати дату й час приміток і відстежуваних змін + The document you are editing keeps the names and dates; only the saved file leaves them out. Документ, який ви редагуєте, зберігає імена й дати; їх не буде лише у збереженому файлі. + Privacy Settings Поточні налаштування конфіденційності diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4c495e93d..86d76978c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2088,3 +2088,10 @@ Start-up 启动 Show the Start screen when WordCraft starts 启动 WordCraft 时显示开始屏幕 On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 开:WordCraft 打开时显示“文件 › 开始”,其中有空白文档、模板和最近使用的文档。关:直接打开空白文档。 + Privacy Options 隐私选项 + Trust Center 信任中心 + Privacy for this document 此文档的隐私 + Remove my name from comments, changes and file properties when saving 保存时从批注、修订和文件属性中删除我的姓名 + Leave out the date and time of comments and tracked changes when saving 保存时不包含批注和修订的日期与时间 + The document you are editing keeps the names and dates; only the saved file leaves them out. 正在编辑的文档保留姓名和日期;只有保存的文件中不包含它们。 + Privacy Settings 当前隐私设置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b0dea43af..3ed9b5bad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2077,3 +2077,10 @@ Start-up 啟動 Show the Start screen when WordCraft starts 啟動 WordCraft 時顯示開始畫面 On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 開:WordCraft 開啟時顯示「檔案 › 常用」,其中有空白文件、範本和最近使用的文件。關:直接開啟空白文件。 + Privacy Options 隱私權選項 + Trust Center 信任中心 + Privacy for this document 此文件的隱私 + Remove my name from comments, changes and file properties when saving 儲存時從註解、修訂和檔案內容中移除我的名稱 + Leave out the date and time of comments and tracked changes when saving 儲存時不包含註解和追蹤修訂的日期與時間 + The document you are editing keeps the names and dates; only the saved file leaves them out. 正在編輯的文件會保留名稱和日期;只有儲存的檔案不包含它們。 + Privacy Settings 目前的隱私設定 diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index d779a42ad..160d3255e 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -316,6 +316,23 @@ fn save(app: &mut WordApp, ui: &mut Ui) { app.session.autosave = autosave; } note(ui, "AutoSave writes your changes to the file a moment after you stop typing."); + ui.add_space(8.0); + // The open document's `file.privacyOptions` (#1062): saved with it, honoured when it is saved. + section(ui, "Privacy for this document"); + let settings = &app.session.doc.settings; + let rows = [ + ("Remove my name from comments, changes and file properties when saving", "removePersonalInfo", settings.remove_personal_info), + ("Leave out the date and time of comments and tracked changes when saving", "removeDateTime", settings.remove_date_time), + ]; + for (label, key, value) in rows { + let mut v = value; + if ui.checkbox(&mut v, tl!(label)).changed() + && let Err(e) = app.run("file.privacyOptions", json!({ key: v })) + { + app.status(e); + } + } + note(ui, "The document you are editing keeps the names and dates; only the saved file leaves them out."); } fn language(app: &mut WordApp, ui: &mut Ui) { @@ -708,6 +725,35 @@ mod tests { assert!(h.query_by_label("Measurement units:").is_some()); } + /// File › Options › Save › Privacy (#1062): the checkboxes are the document's + /// `file.privacyOptions` settings, and an undoable change like any other. + #[test] + fn save_privacy_checkboxes_set_the_privacy_options() { + let mut a = app(); + a.run("file.options", json!({"pane": "save"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + assert!(h.query_by_label("Privacy for this document").is_some(), "the section is there"); + h.get_by_label("Remove my name from comments, changes and file properties when saving").click(); + h.run_steps(4); + assert!(h.state().session.doc.settings.remove_personal_info); + assert!(!h.state().session.doc.settings.remove_date_time); + h.get_by_label("Leave out the date and time of comments and tracked changes when saving").click(); + h.run_steps(4); + let privacy = h.state_mut().session.run("file.privacy", &json!({})).unwrap(); + assert_eq!(privacy, json!({"removePersonalInfo": true, "removeDateTime": true})); + h.get_by_label("Remove my name from comments, changes and file properties when saving").click(); + h.run_steps(4); + assert!(!h.state().session.doc.settings.remove_personal_info, "and off again"); + } + /// File › Options › Accessibility › Animation effects: Use system setting (the default), On /// or Off, with a line saying what the system's own setting is while it is followed. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0777606f6..25810aa88 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: undrawn chart types show a placeholder, #998; previously trivial: saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; 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-13 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: Remove personal information and Remove date and time honoured on save, #1062; previously trivial: undrawn chart types show a placeholder, #998; previously trivial: saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; 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; @@ -71,7 +71,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. No native stroke rendering, eraser, conversion or replay; ink nested in groups/canvases remains unsupported | | 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | -| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | +| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) | | Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | @@ -164,6 +164,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| | 2026-10-12 | trivial | VML text boxes keep their internal margins (`inset`), fit to their text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`); hidden drawings (`visibility:hidden`, `wp:docPr/@hidden`) are kept but not shown | +| 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | From c3d45adeab15af778ddf7ffc3552a0c592210b53 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:01:56 +0200 Subject: [PATCH 024/199] Comment balloon buttons work, so an empty comment can be deleted (#1085) (#1132) A press on a selected balloon's Delete, Reply or Resolve button counted as a click on the page and closed the balloon before the button saw its click. Presses inside a balloon no longer leave it. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/engine/src/tests.rs | 21 +++++++++++++++ crates/ui-egui/src/canvas.rs | 52 ++++++++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 015905e64..c23951f64 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: sample-text formulas on Enter, #1067; previously trivial: direct run-level native InkML content parts survive DOCX saves; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: a comment balloon's Delete, Reply and Resolve buttons work, #1085; previously trivial: sample-text formulas on Enter, #1067; previously trivial: direct run-level native InkML content parts survive DOCX saves; previously trivial: progress log: 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,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm |---|---| | 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | | 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both | +| 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon | | 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo | | 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet | | 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | @@ -279,6 +280,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings | +| 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | | 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e0b71323c..ed82ad3bf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1733,6 +1733,27 @@ fn comments() { assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 0); } +/// #1085: a new comment with no text, in an empty document or on a word, goes with Review › +/// Delete at the caret (no id) and by id. +#[test] +fn empty_comments_delete() { + for body in ["", "Some text here"] { + for by_id in [false, true] { + let mut s = Session::new(wordcraft_doc::Document::new()); + if !body.is_empty() { + run(&mut s, "text.insert", json!({"text": body})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": 6}})); + } + let id = run(&mut s, "review.newComment", json!({}))["id"].clone(); + assert_eq!(run(&mut s, "review.comments", json!({}))[0]["text"], ""); + run(&mut s, "review.deleteComment", if by_id { json!({"id": id}) } else { json!({}) }); + assert!(s.doc.comments.is_empty(), "{body:?} by id {by_id}"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 0, "{body:?}: anchors gone"); + assert_eq!(text(&s), body); + } + } +} + #[test] fn toc_and_fields() { let mut s = Session::new(crate::sample::report()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a6369096..0b1ba01a0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1229,8 +1229,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // the scroll area is built, so it reads last frame's viewport. app.canvas.viewport = Some(out.inner_rect); let (resp, rects) = out.inner; - // A click on the page leaves the selected comment balloon. - if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() { + // A click on the page leaves the selected comment balloon; a click inside a balloon (its + // Delete or Resolve button, say) does not (#1085). + let in_balloon = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| app.canvas.balloon_rects.iter().any(|(_, r)| r.contains(p))); + if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() && !in_balloon { deselect_balloon(app, ui.ctx()); } // Focus: the canvas takes keyboard focus on click and keeps Tab/arrows. @@ -2937,6 +2939,52 @@ mod tests { assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); } + /// #1085: a selected balloon's buttons work (pressing them is not a click on the page that + /// leaves the balloon): a new comment with no text resolves and deletes from its balloon. + #[test] + fn an_empty_comment_balloon_deletes() { + for body in ["", "Some text here"] { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + if !body.is_empty() { + a.run("text.insert", json!({"text": body})).unwrap(); + a.run("select.text", json!({"text": "text"})).unwrap(); + } + let id = a.run("review.newComment", json!({})).unwrap()["id"].as_u64().unwrap() as u32; + a.run("view.commentsPane", json!({"value": false})).unwrap(); + 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 click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: egui::Pos2| { + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + for _ in 0..3 { + h.step(); + } + }; + let card = h.state().canvas.balloon_rects.iter().find(|(i, _)| *i == id).map(|(_, r)| *r).expect("the comment has a balloon"); + click(&mut h, card.left_top() + vec2(30.0, 8.0)); + assert_eq!(h.state().canvas.balloon, Some(id), "{body:?}: the click selected the balloon"); + use egui_kittest::kittest::Queryable; + let resolve = h.get_by_label("Resolve").rect().center(); + click(&mut h, resolve); + assert!(h.state().session.doc.comments[&id].resolved, "{body:?}: Resolve worked"); + assert_eq!(h.state().canvas.balloon, Some(id), "{body:?}: the balloon stays selected"); + let del = h.get_by_label("Delete").rect().center(); + click(&mut h, del); + assert!(h.state().session.doc.comments.is_empty(), "{body:?}: the empty comment was deleted"); + } + } + /// A typed character is laid out and painted in the frame that delivers the key, not the next /// one: keys are handled before the canvas takes the layout it paints. #[test] From bf9b84f10b3904fd10e9a13bf4851d52ae742660 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:02:15 +0200 Subject: [PATCH 025/199] =?UTF-8?q?Start=20screen:=20no=20blank=20document?= =?UTF-8?q?=20behind=20File=20=E2=80=BA=20Home=20(#1129)=20(#1134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #1129: no document behind the Start screen; Back shows an empty window Launched with nothing to open, the Start screen no longer has a blank document behind it. Esc or Back shows an empty window (no page, ribbon greyed out but for Help, Info/Save/Save As/Print/Export off in File, title "WordCraft") until Blank document, a template or a file is opened; closing asks nothing. Commands that need a document say so. Co-Authored-By: Claude Opus 5.5 * #1129: explicit no-document allow-list; control inspect/render need a document; fonts can be added works_without_document lists the ui.* commands that make sense with no document instead of letting every ui.* command through; font.add/list/remove work so a font file dropped on the empty window is added. The control channel's document.inspect and ui.render answer that no document is open instead of reading the hidden placeholder. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/ui-egui/src/backstage.rs | 25 ++- crates/ui-egui/src/chrome.rs | 33 ++-- crates/ui-egui/src/control.rs | 5 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr-latn.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 226 +++++++++++++++++++++++++--- crates/ui-egui/src/ribbon.rs | 8 + crates/ui-egui/src/widgets.rs | 4 + docs/ui-parity.md | 5 +- 26 files changed, 282 insertions(+), 46 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c23951f64..3dfc7f68d 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: a comment balloon's Delete, Reply and Resolve buttons work, #1085; previously trivial: sample-text formulas on Enter, #1067; previously trivial: direct run-level native InkML content parts survive DOCX saves; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: no document behind the Start screen, #1129; 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. @@ -153,6 +153,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) | | 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP | | 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off | +| 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing | | 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 | | 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character | | 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s | @@ -291,6 +292,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 | | 2026-10-12 | trivial | Progress log: Undo history list (#701) | | 2026-10-12 | trivial | Progress log: Start screen at launch (#766) | +| 2026-10-13 | trivial | Progress log: no document behind the Start screen (#1129) | | 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed | | 2026-10-12 | trivial | Progress log: ribbon groups fit above their labels | | 2026-10-12 | trivial | Font list previews no longer grow memory (#848) | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 6cc225275..28dc72fe0 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -21,6 +21,11 @@ const PAGES: [(&str, &str); 10] = [ pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // No document open (#1129): only the pages that open or create one, and Options. + let has_document = app.has_document(); + if !has_document && !works_without_document(&app.ui.backstage_page) { + app.ui.backstage_page = "home".into(); + } let mac = app.integrated_titlebar; egui::Panel::left("backstage_nav") .exact_size(200.0) @@ -59,12 +64,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } ui.add_space(if mac { 12.0 } else { 6.0 }); for (id, label) in PAGES { - let (r, resp) = ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label))); + // No document open (#1129): Info, Save, Save As, Print and Export are greyed out. + let on = has_document || works_without_document(id); + let (r, resp) = ui.add_enabled_ui(on, |ui| ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click())).inner; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!(label))); let active = app.ui.backstage_page == id; if active { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46)); - } else if resp.hovered() { + } else if on && resp.hovered() { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26)); } ui.painter().text( @@ -72,7 +79,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { Align2::LEFT_CENTER, tl!(label), if active { semibold(14.0) } else { medium(14.0) }, - egui::Color32::WHITE, + if on { egui::Color32::WHITE } else { egui::Color32::from_white_alpha(110) }, ); if resp.clicked() { match id { @@ -135,6 +142,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); } +/// The Backstage pages that work while no document is open (#1129). +fn works_without_document(page: &str) -> bool { + matches!(page, "home" | "new" | "open" | "options") +} + fn heading(ui: &mut Ui, s: &str) { ui.label(egui::RichText::new(tl!(s)).font(semibold(26.0))); ui.add_space(16.0); @@ -176,8 +188,9 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { if let Some(h) = tex { ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); } - ui.label(egui::RichText::new(tl!(label)).font(regular(12.5))); - if resp.clicked() { + // The caption opens the template too, as in Word. + let caption = ui.add(egui::Label::new(egui::RichText::new(tl!(label)).font(regular(12.5))).sense(Sense::click())); + if resp.clicked() || caption.clicked() { let _ = app.run("file.new", json!({"template": template})); app.ui.backstage = false; app.canvas.want_focus = true; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 90f0fccd2..acfff721c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -32,7 +32,7 @@ pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool { match id { "edit.undo" => app.session.can_undo(), "edit.redo" => app.session.can_redo(), - "file.save" => true, + "file.save" => app.has_document(), _ => crate::widgets::enabled(app, id), } } @@ -315,17 +315,22 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { }); }); // Centred title. - let title = format!( - "{} — {}", - app.title_stem(), - if app.session.dirty { - tl!("Edited") - } else if app.session.path.is_some() { - tl!("Saved") - } else { - tl!("Not saved") - } - ); + let title = if !app.has_document() { + // No document open (#1129): the app's name alone. + "WordCraft".to_string() + } else { + format!( + "{} — {}", + app.title_stem(), + if app.session.dirty { + tl!("Edited") + } else if app.session.path.is_some() { + tl!("Saved") + } else { + tl!("Not saved") + } + ) + }; let font = TypeRung::Chrome.semibold(); let track = TypeRung::Chrome.tracking(); let w = text_width(ui.painter(), &title, &font, track, t.text); @@ -355,6 +360,10 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { .stroke(Stroke::new(1.0, t.border)), ) .show(ui, |ui| { + // No document open (#1129): an empty status bar. + if !app.has_document() { + return; + } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing = vec2(14.0, 0.0); let l = app.session.layout(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dd9b5992b..8094eeb24 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -105,6 +105,9 @@ fn click_events(app: &mut WordApp, pos: egui::Pos2, button: egui::PointerButton, } } +/// The reply to a request that needs a document while none is open (#1129). +const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first"; + pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -134,6 +137,8 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O } ok(Value::Array(commands)) } + // The Start screen's empty window has no document to inspect or render (#1129). + "document.inspect" | "ui.render" if !app.has_document() => err(NO_DOCUMENT), "document.inspect" => match app.session.run("document.inspect", p) { Ok(v) => ok(v), Err(e) => err(e), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f25bc3d9f..c7a188717 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -95,6 +95,7 @@ Legal style numbering Номериране в юридически стил Link level to style: Свързване на нивото със стил: List Definition Дефиниция на списък + No document is open. Create a document or open one first. Няма отворен документ. Първо създайте или отворете документ. Nothing Нищо Number alignment: Подравняване на номера: Number format Формат на номера diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ce4c107a5..8bff692a2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -301,6 +301,7 @@ Deletions: Odstranění: Description: Popis: Design Návrh + No document is open. Create a document or open one first. Není otevřen žádný dokument. Nejprve dokument vytvořte nebo otevřete. development build vývojové sestavení Diagonal Diagonální Diamond Kosočtverec diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4bcb12199..3d3733fcf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -104,6 +104,7 @@ Link level to style: Ebene mit Formatvorlage verknüpfen: List Definition Listendefinition More templates → Weitere Vorlagen → + No document is open. Create a document or open one first. Es ist kein Dokument geöffnet. Erstellen oder öffnen Sie zuerst ein Dokument. No templates match your search. Keine Vorlagen entsprechen Ihrer Suche. Nothing Keine Number alignment: Nummernausrichtung: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dfb2850c8..8cd3e5604 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -116,6 +116,7 @@ Millimeters Milímetros New comments and tracked changes are recorded under this name and these initials. Los comentarios y cambios con seguimiento nuevos se registran con este nombre y estas iniciales. New word Palabra nueva + No document is open. Create a document or open one first. No hay ningún documento abierto. Crea uno o abre uno primero. No words yet. Add to Dictionary on a misspelled word puts it here. Aún no hay palabras. Añadir al diccionario sobre una palabra mal escrita la pone aquí. Mirror indents Sangrías simétricas Nothing Nada diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index c5426c901..85d5ec8ee 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -482,6 +482,7 @@ No Paragraph Space Lõiguvaheta No Spacing Vahedeta No contributor data was built into this copy. Selles versioonis pole kaasautorite andmeid. + No document is open. Create a document or open one first. Ükski dokument pole avatud. Looge või avage kõigepealt dokument. No model credits were built into this copy. Selles versioonis pole mudelite autoriteavet. No templates match your search. Otsingule vastavaid malle pole. No words yet. Add to Dictionary on a misspelled word puts it here. Sõnu veel pole. Valik „Lisa sõnastikku“ valesti kirjutatud sõnal lisab selle siia. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2d983c7ae..265a7892f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -96,6 +96,7 @@ Link level to style: Lier le niveau au style : List Definition Définition de liste Mirror indents Retraits en miroir + No document is open. Create a document or open one first. Aucun document n’est ouvert. Créez ou ouvrez d’abord un document. Nothing Rien Number alignment: Alignement des numéros : Number format Format des numéros diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fc15e281a..8748764df 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -191,6 +191,7 @@ Delete Style Stílus törlése Delete Table Táblázat törlése Design Tervezés + No document is open. Create a document or open one first. Nincs megnyitott dokumentum. Előbb hozz létre vagy nyiss meg egyet. development build fejlesztői kiadás Diagonal Átlós Diamond Rombusz diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fa8a43459..8ce06c312 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -93,6 +93,7 @@ Legal style numbering Numerazione stile legale Link level to style: Collega livello allo stile: List Definition Definizione elenco + No document is open. Create a document or open one first. Nessun documento aperto. Crea o apri prima un documento. Nothing Niente Number alignment: Allineamento numero: Number format Formato numero diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 98941975e..b198a3dd3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -120,6 +120,7 @@ More templates → その他のテンプレート → New comments and tracked changes are recorded under this name and these initials. 新しいコメントと変更履歴は、この名前とイニシャルで記録されます。 New word 新しい単語 + No document is open. Create a document or open one first. 開いている文書がありません。先に文書を作成するか開いてください。 No templates match your search. 検索に一致するテンプレートはありません。 No words yet. Add to Dictionary on a misspelled word puts it here. まだ単語はありません。スペルミスの単語で [辞書に追加] を選ぶと、ここに追加されます。 Nothing なし diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c07ae73a6..17cb3c2db 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -451,6 +451,7 @@ No Markup Bez znaczników No Paragraph Space Bez odstępów między akapitami No contributor data was built into this copy. Ta kopia programu nie zawiera danych o współtwórcach. + No document is open. Create a document or open one first. Żaden dokument nie jest otwarty. Najpierw utwórz lub otwórz dokument. No model credits were built into this copy. Ta kopia programu nie zawiera informacji o modelach. No templates match your search. Żaden szablon nie pasuje do wyszukiwania. Nonbreaking Hyphen Łącznik nierozdzielający diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3b7424701..3649b3d55 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -117,6 +117,7 @@ Millimeters Milímetros New comments and tracked changes are recorded under this name and these initials. Novos comentários e alterações controladas são registrados com este nome e estas iniciais. New word Nova palavra + No document is open. Create a document or open one first. Nenhum documento está aberto. Crie ou abra um documento primeiro. No words yet. Add to Dictionary on a misspelled word puts it here. Ainda não há palavras. Adicionar ao dicionário em uma palavra com erro a coloca aqui. Mirror indents Recuos espelhados Nothing Nada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bd65ce210..ef0250799 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -189,6 +189,7 @@ Delete Style Удалить стиль Delete Table Удалить таблицу Design Дизайн + No document is open. Create a document or open one first. Нет открытого документа. Сначала создайте или откройте документ. Search Поиск Title Bar Строка заголовка development build сборка для разработки diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 4e34126c6..5e2e5d546 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -142,6 +142,7 @@ Millimeters Milimetri New comments and tracked changes are recorded under this name and these initials. Novi komentari i praćene izmene beleže se pod ovim imenom i inicijalima. New word Nova reč + No document is open. Create a document or open one first. Nijedan dokument nije otvoren. Prvo napravite ili otvorite dokument. No words yet. Add to Dictionary on a misspelled word puts it here. Još nema reči. Dodaj u rečnik na pogrešno napisanoj reči stavlja je ovde. Mirror indents Preslikaj uvlačenja Nothing Ništa diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f48404694..eca06b5d5 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -146,6 +146,7 @@ Millimeters Милиметри New comments and tracked changes are recorded under this name and these initials. Нови коментари и праћене измене бележе се под овим именом и иницијалима. New word Нова реч + No document is open. Create a document or open one first. Ниједан документ није отворен. Прво направите или отворите документ. No words yet. Add to Dictionary on a misspelled word puts it here. Још нема речи. Додај у речник на погрешно написаној речи ставља је овде. Mirror indents Пресликај увлачења Nothing Ништа diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index aa88c064c..8940a1712 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -189,6 +189,7 @@ Delete Style Stili Sil Delete Table Tabloyu Sil Design Tasarım + No document is open. Create a document or open one first. Açık belge yok. Önce bir belge oluşturun veya açın. development build geliştirme derlemesi Diagonal Çapraz Diamond Eşkenar Dörtgen diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1715604e8..97acd700d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -119,6 +119,7 @@ Millimeters Міліметри New comments and tracked changes are recorded under this name and these initials. Нові коментарі та відстежувані зміни записуються під цим іменем і цими ініціалами. New word Нове слово + No document is open. Create a document or open one first. Немає відкритого документа. Спочатку створіть або відкрийте документ. No words yet. Add to Dictionary on a misspelled word puts it here. Слів ще немає. «Додати до словника» для слова з помилкою додає його сюди. Mirror indents Дзеркальні відступи Nothing Нічого diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 86d76978c..3f75f776f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -132,6 +132,7 @@ More templates → 更多模板 → New comments and tracked changes are recorded under this name and these initials. 新批注和修订将以此名称和缩写记录。 New word 新单词 + No document is open. Create a document or open one first. 没有打开的文档。请先新建或打开一个文档。 No templates match your search. 没有与搜索匹配的模板。 No words yet. Add to Dictionary on a misspelled word puts it here. 还没有单词。对拼写错误的单词选择“添加到词典”会将其放在这里。 Nothing 无 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3ed9b5bad..3cd893f09 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -121,6 +121,7 @@ More templates → 更多範本 → New comments and tracked changes are recorded under this name and these initials. 新的註解和追蹤修訂會以此名稱和縮寫記錄。 New word 新單字 + No document is open. Create a document or open one first. 沒有開啟的文件。請先新增或開啟文件。 No templates match your search. 沒有符合搜尋的範本。 No words yet. Add to Dictionary on a misspelled word puts it here. 還沒有單字。對拼字錯誤的單字選擇「新增至字典」會將它放在這裡。 Nothing 無 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 15dee0aaa..babaa0cf3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -436,6 +436,11 @@ pub struct WordApp { /// keystroke, so one visit's keystrokes make a single undo step (and anything else in /// between, such as an Undo, starts a new one). pub(crate) info_editing: Option<(&'static str, u64)>, + /// Started on the Start screen with nothing to open (#1129): the id of the placeholder + /// blank document the session holds until the user creates or opens one. While it is still + /// the session's document, no document is open: the window shows no page and only the File + /// tab, Help and settings work. See [`WordApp::has_document`]. + no_document: Option, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -519,6 +524,7 @@ impl WordApp { #[cfg(not(target_arch = "wasm32"))] file_browser: file_browser::State::default(), info_editing: None, + no_document: None, } } @@ -528,19 +534,27 @@ impl WordApp { } /// Start-up with no document to open (#766): show File › Home (Blank document, the templates - /// and Recent), as Word starts, when the Start screen setting is on (the default). Esc or the - /// back arrow leaves the empty document the app was created with. Hosts call this after - /// [`WordApp::apply_prefs`] when they have nothing to open; [`WordApp::new`] never does, so - /// tests, `ui_shot` and windows opened for a document start in the editor. Returns whether - /// the Start screen shows. + /// and Recent), as Word starts, when the Start screen setting is on (the default). No document + /// is open until the user picks Blank document or a template or opens a file (#1129): Esc or + /// the back arrow shows an empty window with the ribbon greyed out, as Word does. Hosts call + /// this after [`WordApp::apply_prefs`] when they have nothing to open; [`WordApp::new`] never + /// does, so tests, `ui_shot` and windows opened for a document start in the editor. Returns + /// whether the Start screen shows. pub fn start_screen(&mut self) -> bool { if self.ui.start_screen { self.ui.backstage = true; self.ui.backstage_page = "home".into(); + self.no_document = Some(self.session.document_id()); } self.ui.start_screen } + /// Whether a document is open: false from a Start screen launch until the user creates or + /// opens one (#1129). + pub fn has_document(&self) -> bool { + self.no_document != Some(self.session.document_id()) + } + /// Preferences to save between runs: the UI state plus the current user name and editing /// preferences. pub fn prefs(&self) -> UiState { @@ -649,6 +663,13 @@ impl WordApp { if !matches!(id, "ui.openRecipientList" | "mailings.recipients") { self.recipient_list_pending = false; } + // No document open (#1129): only the commands that open or create one, Help, and the + // settings work. + if !self.has_document() && !works_without_document(id) { + let e = tl!("No document is open. Create a document or open one first.").to_string(); + self.status(e.clone()); + return Err(e); + } if let Some(r) = self.ui_command(id, ¶ms) { return r; } @@ -841,7 +862,8 @@ impl WordApp { self.status(tl!("Wait for the print job to finish submitting before closing.")); return false; } - if self.quit_requested || !self.session.dirty { + // No document open (#1129): nothing to save. + if self.quit_requested || !self.session.dirty || !self.has_document() { return true; } let _ = self.run("file.close", json!({})); @@ -1517,6 +1539,12 @@ impl WordApp { crate::keytips::logic(self, &ctx); if self.ui.backstage { backstage::show(self, ui); + } else if !self.has_document() { + // No document open (#1129): the ribbon greyed out but for Help, and no page. + chrome::title_bar(self, ui); + ribbon::show(self, ui); + chrome::status_bar(self, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |_| {}); } else { chrome::title_bar(self, ui); ribbon::show(self, ui); @@ -1538,7 +1566,11 @@ impl WordApp { if let Some(url) = self.canvas.open_url.take() { ctx.open_url(egui::OpenUrl::new_tab(url)); } - let title = format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" }); + let title = if self.has_document() { + format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" }) + } else { + "WordCraft".to_string() + }; if title != self.sent_title { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.sent_title = title; @@ -1767,6 +1799,51 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { } } +/// Commands that work while no document is open (#1129): opening or creating one, closing the +/// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File › +/// Options, the view and interface preferences it sets, Customize Ribbon and Keyboard). +/// Everything else needs a document, `ui.*` included: only the interface commands listed here +/// (tabs, Backstage, dialogs, appearance, opening files) work without one. +pub(crate) fn works_without_document(id: &str) -> bool { + matches!( + id, + "file.new" + | "file.open" + | "file.close" + | "file.options" + | "file.setAuthor" + | "font.add" + | "font.list" + | "font.remove" + | "readAloud.speed" + | "readAloud.skipCitations" + | "tools.customizeKeyboard" + | "tools.customizeRibbon" + | "view.darkMode" + | "view.marks" + | "view.ruler" + | "view.numeral" + | "ui.tab" + | "ui.backstage" + | "ui.dialog" + | "ui.closeDialog" + | "ui.collapseRibbon" + | "ui.keytips" + | "ui.dark" + | "ui.theme" + | "ui.interfaceSize" + | "ui.animations" + | "ui.language" + | "ui.saveChanges" + | "ui.openFileDialog" + | "ui.fileBrowser" + | "ui.makeDefaultApp" + | "ui.discord" + | "ui.caretAnimation" + | "ui.startScreen" + ) || id.starts_with("help.") +} + /// User commands that replace or close the document (`file.open` without a path only shows the /// file picker; the open that follows is checked). Envelopes, Labels and Finish & Merge make a /// new document in its place; a merge written to a file (`path`) and an envelope added to this @@ -1908,39 +1985,118 @@ mod tests { assert_eq!(a.run("ui.caretAnimation", json!({"value": true})).unwrap()["caretAnimation"], true); } - /// #766: started with nothing to open, the app shows File › Home (the Start screen) as Word - /// does; Esc leaves the empty blank document it was created with. With the setting off it - /// starts in the editor, and `WordApp::new` alone (tests, ui_shot, windows opened for a - /// document) never shows it. - #[test] - fn start_screen_shows_file_home_and_esc_leaves_a_blank_document() { - use egui_kittest::kittest::Queryable; - let mut a = app(); - assert!(!a.ui.backstage, "`new` starts in the editor"); - a.ui.backstage_page = "print".into(); - assert!(a.start_screen(), "on by default"); - assert!(a.ui.backstage && a.ui.backstage_page == "home"); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { + 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, - ); + ) + } + + /// #766: started with nothing to open, the app shows File › Home (the Start screen) as Word + /// does. #1129: no document is open behind it, so Esc (or Back) shows an empty window, with + /// no page, the ribbon greyed out but for Help, and nothing to save on close. With the setting + /// off it starts in the editor, and `WordApp::new` alone (tests, ui_shot, windows opened for a + /// document) never shows it. + #[test] + fn start_screen_shows_file_home_and_esc_leaves_no_document() { + use egui_kittest::kittest::Queryable; + let mut a = app(); + assert!(!a.ui.backstage, "`new` starts in the editor"); + assert!(a.has_document()); + a.ui.backstage_page = "print".into(); + assert!(a.start_screen(), "on by default"); + assert!(a.ui.backstage && a.ui.backstage_page == "home"); + assert!(!a.has_document(), "no document behind the Start screen"); + let mut h = harness(a); h.run_steps(4); assert!(h.query_by_label("Blank document").is_some(), "Home lists Blank document and the templates"); assert!(h.query_by_label("Recent").is_some(), "and the Recent list"); h.key_press(egui::Key::Escape); h.run_steps(4); - assert!(!h.state().ui.backstage, "Esc leaves the Start screen"); - assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "", "for an empty blank document"); - assert!(!h.state().session.dirty); + let a = h.state_mut(); + assert!(!a.ui.backstage, "Esc leaves the Start screen"); + assert!(!a.has_document(), "for an empty window, not a blank document"); + assert_eq!(a.sent_title, "WordCraft", "the window is titled with the app alone"); + // The ribbon's commands are off, but Help, the File tab and the settings work. + for id in ["format.bold", "text.insert", "file.save", "file.saveAs", "file.print", "edit.paste", "insert.table", "ui.print"] { + assert!(!widgets::enabled(a, id), "{id} is off with no document"); + } + for id in ["file.new", "file.open", "ui.backstage", "ui.tab", "ui.discord", "file.options", "ui.theme"] { + assert!(widgets::enabled(a, id), "{id} works with no document"); + } + assert!(!chrome::qat_enabled(a, "file.save")); + // Only the listed interface commands work: the ones that act on the document don't. + for id in ["ui.contextMenu", "ui.render", "ui.linkTextBox", "ui.zotero.refresh", "ui.unlistedCommand"] { + assert!(!works_without_document(id), "{id} needs a document"); + } + let refused = a.run("ui.zotero.status", json!({})).unwrap_err(); + assert!(refused.contains("No document is open"), "refused before it runs: {refused}"); + assert!(works_without_document("font.add"), "a font file dropped on the empty window is added"); + // Neither keys nor commands reach the hidden placeholder. + assert!(a.run("text.insert", json!({"text": "Hello"})).is_err()); + assert!(a.execute("format.bold", json!({})).is_err()); + h.event(egui::Event::Text("typed".into())); + h.key_press(egui::Key::Enter); + h.run_steps(2); + let a = h.state_mut(); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), ""); + assert!(!a.session.dirty && !a.has_document()); + assert!(a.close_requested(), "closing asks nothing"); + assert!(a.dialog.is_none()); let mut b = app(); b.run("ui.startScreen", json!({"value": false})).unwrap(); assert!(!b.start_screen(), "off: today's behaviour"); - assert!(!b.ui.backstage); + assert!(!b.ui.backstage && b.has_document()); + } + + /// #1129: the empty window's File tab goes back to the Backstage with only Home, New, Open + /// and Options; picking Blank document there opens one, ready for typing. + #[test] + fn blank_document_opens_a_document_from_the_start_screen() { + use egui_kittest::kittest::Queryable; + let mut a = app(); + a.start_screen(); + a.run("ui.backstage", json!({"value": false})).unwrap(); + a.run("ui.backstage", json!({"value": true, "page": "info"})).unwrap(); + let first = a.session.document_id(); + let mut h = harness(a); + h.run_steps(4); + assert_eq!(h.state().ui.backstage_page, "home", "Info needs a document"); + h.get_by_label("Blank document").click(); + h.run_steps(4); + let a = h.state_mut(); + assert!(a.has_document() && !a.ui.backstage, "Blank document opens one"); + assert_ne!(a.session.document_id(), first); + assert!(widgets::enabled(a, "format.bold")); + a.run("text.insert", json!({"text": "Hello"})).unwrap(); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Hello"); + assert!(a.sent_title.starts_with(&a.title_stem()), "{}", a.sent_title); + } + + /// #1129: a template or a file opened from the Start screen (or handed over later) is the + /// document; a failed open leaves the window empty. + #[test] + fn templates_and_files_open_from_the_start_screen() { + let mut a = app(); + a.start_screen(); + a.run("file.new", json!({"template": "letter"})).unwrap(); + assert!(a.has_document()); + + let dir = scratch("start-screen-open"); + let path = docx_on_disk(&dir, "Signed copy"); + let mut b = app(); + b.start_screen(); + assert!(b.run("file.open", json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err()); + assert!(!b.has_document(), "a failed open opens nothing"); + b.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert!(b.has_document()); + assert!(body_text(&b).contains("Signed copy")); + let _ = std::fs::remove_dir_all(dir); } /// The Start screen setting is a UI command, kept between runs; settings saved before it @@ -4236,6 +4392,26 @@ mod tests { assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view"); } + /// #1129: with no document open (the Start screen's empty window), `document.inspect` and + /// `ui.render` on the control channel say so instead of reading the hidden placeholder. + #[test] + fn control_inspect_and_render_need_a_document() { + let ctx = egui::Context::default(); + let mut a = app(); + a.start_screen(); + assert!(!a.has_document()); + for (method, params) in [("document.inspect", json!({})), ("ui.render", json!({"page": 1, "scale": 0.5}))] { + let (req, _reply) = ControlRequest::new(method, params); + let control::Outcome::Done(v) = control::handle(&mut a, &ctx, &req) else { panic!("{method} replies at once") }; + assert_eq!(v["ok"], false, "{method}: {v}"); + assert!(v["error"].as_str().is_some_and(|e| e.contains("no document is open")), "{method}: {v}"); + } + a.run("file.new", json!({})).unwrap(); + let (req, _reply) = ControlRequest::new("document.inspect", json!({})); + let control::Outcome::Done(v) = control::handle(&mut a, &ctx, &req) else { panic!("document.inspect replies at once") }; + assert_eq!(v["ok"], true, "with a document it works: {v}"); + } + /// `ui.render` and `ui.screenshot` without a `path` return the PNG in the reply instead of /// writing a fixed name into the shared temporary folder, where another user could have /// planted a link to one of this user's files. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c6ac30bc4..11ab67442 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -225,6 +225,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // No document open (#1129): nothing to share, edit or comment on. + if !app.has_document() { + ui.disable(); + } // Share, Editing mode, Comments. let share = tl!("Share"); let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x); @@ -273,6 +277,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .show(ui, |ui| { // Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so // the buttons drawn over it keep their own clicks. + // No document open (#1129): every command is greyed out but Help's. + if !app.has_document() && app.ui.tab != "Help" { + ui.disable(); + } let bg = ui.interact(ui.max_rect(), ui.id().with("ribbon_background"), Sense::click()); bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); let entry = user_tabs.iter().find(|e| e.name == app.ui.tab); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3246a0a71..ad4adcdd3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -90,6 +90,10 @@ fn keytip_badge(ui: &Ui, rect: Rect, id: &str, name: &str) { /// Whether a command exists and is enabled. pub fn enabled(app: &WordApp, id: &str) -> bool { + // No document open (#1129): only what opens one, Help and the settings. + if !app.has_document() && !crate::works_without_document(id) { + return false; + } match app.session.registry.get(id) { Some(s) => (s.enabled)(&app.session).is_none(), // Zotero: one command at a time, and only in the desktop app. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 58f85401a..b63b4a023 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-11 · **Change:** 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-13 · **Change:** trivial (no document behind the Start screen, #1129; 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 @@ -21,7 +21,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | -| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; Esc leaves a blank document), with File › Options › General › Start-up to turn it off (#766) | | 68% | 4–6 | +| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 85% | 1–2 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — | | Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it; selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows. Missing: a single selected cell's border | ✅ | 95% | 1 | @@ -52,6 +52,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Tables on the page: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells, as in Word | | 2026-10-11 | trivial | Tables on the page: selection areas for columns, rows and cells, "+" insert buttons, the move handle and double-click AutoFit; Select Column in the ribbon | | 2026-10-12 | trivial | Quick Access Toolbar: Undo list to undo several steps at once (#701) | +| 2026-10-13 | trivial | Start screen (#1129): no document is created behind it; Esc or Back shows an empty window (no page, ribbon greyed out but for Help, Info/Save/Save As/Print/Export off in File, title "WordCraft") until Blank document, a template or a file is opened; closing asks nothing; commands that need a document say so (only listed interface commands and font.add work without one; the control channel's document.inspect and ui.render say no document is open) | | 2026-10-12 | trivial | Start screen (#766): launched with no file, the app opens on File › Home (Blank document, templates, Recent) as Word does; Esc or the back arrow leaves an empty blank document; File › Options › General › Start-up turns it off, kept between runs; `ui.startScreen` for agents. Not shown with files, `--sample` or `--control` | | 2026-10-12 | trivial | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819) | | 2026-10-12 | trivial | Ribbon groups fit above their labels: View › Show puts Hide White Space (#645) in a second column instead of a fourth row over the "Show" label; Developer › Controls drops the gaps that pushed it into its label; Layout › Paragraph (#654) puts its column gap inside cells (no `add_space` in the grid, a debug assertion) and 1.5 pt between rows so it fits; `ribbon_groups_fit_above_their_labels` checks every tab in every language | From 9fd3490e4c1cd7b7fa5b772b96a4216c92eb8353 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:02:34 +0200 Subject: [PATCH 026/199] Caret contrasts with the page colour, shading or highlight under it (#299) (#1137) * #299: caret contrasts with the paper, page colour, shading or highlight under it The caret was black (white only in Switch Modes' dark page), so it vanished on a dark page colour, dark shading or a dark highlight. It now takes black or white by WCAG contrast with the background at the caret (wordcraft_render::background_at), cached per layout/view/position. Co-Authored-By: Claude Opus 5.5 * #299: shapes colour the caret's background only inside their outline; look up only when drawn background_at hit-tests a filled shape's real outline (shape_path), so an ellipse's corner shows what lies beneath. The caret's colour is worked out only when the caret (or IME text) is drawn, and the unused Tokens::caret is gone. Co-Authored-By: Claude Opus 5.5 * #299: rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/render/src/lib.rs | 102 +++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 130 ++++++++++++++++++++++++++++++----- crates/ui-egui/src/theme.rs | 3 - docs/ui-parity.md | 5 +- 5 files changed, 221 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3dfc7f68d..fff44b34d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo | | 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet | | 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | +| 2026-10-13 | The caret is black or white, whichever shows against what lies under it — the paper, a page colour, a dark page, shading or a highlight — whatever the UI theme, as Word's inverted caret does; before, it stayed black on dark page colours and highlights and could vanish (#299) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -284,6 +285,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | | 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves | +| 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b41ed2fa4..b17601799 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -430,6 +430,57 @@ pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, Rendered { width: w16 as u32, height: h16 as u32, pixels } } +/// The on-screen colour behind page point (`x`, `y`): the paper (page colour), then any +/// shading, highlight or filled shape drawn over that point, blended in drawing order. Text, +/// pictures and turned or rotated content are left out. The caret takes its colour from this +/// so it shows against whatever lies behind it (#299). +pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, y: f32) -> Rgb { + let paper = opts.ink(doc.settings.page_color.unwrap_or(opts.paper)); + if !(x.is_finite() && y.is_finite()) { + return paper; + } + let mut bg = [paper.0 as f32, paper.1 as f32, paper.2 as f32]; + let items = page_display(doc, page, &opts.display); + blend_backgrounds(&items, wordcraft_geom::Point::new(x, y), opts, &mut bg, 0); + let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Rgb(c(bg[0]), c(bg[1]), c(bg[2])) +} + +fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOptions, bg: &mut [f32; 3], depth: u32) { + if depth > 16 { + return; + } + for it in items { + let (color, alpha) = match it { + Draw::Fill { rect, color, alpha } if rect.contains(p) => (*color, *alpha), + // The shape's own outline (an ellipse's corners are not inside it). Shape fills are + // drawn opaque (the model has no fill transparency), so they cover what's beneath. + Draw::Shape { rect, kind, fill: Some(fill), .. } + if *kind != ShapeKind::Line && rect.contains(p) && { + let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); + kurbo::Shape::contains(&shape_path(*kind, r), kurbo::Point::new(p.x as f64, p.y as f64)) + } => + { + (*fill, 1.0) + } + Draw::Clip { rect, items } if rect.contains(p) => { + blend_backgrounds(items, p, opts, bg, depth + 1); + continue; + } + Draw::Figure { draws, .. } => { + blend_backgrounds(draws, p, opts, bg, depth + 1); + continue; + } + _ => continue, + }; + let a = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + let c = opts.ink(color); + for (v, n) in bg.iter_mut().zip([c.0, c.1, c.2]) { + *v += (n as f32 - *v) * a; + } + } +} + /// One line of `text` in `face` at `size` points, black on white, in a `w`×`h` point raster at /// `scale` pixels per point, starting `margin` points in with the baseline `margin` + the face's /// ascent down: a font list entry. Outlines come straight from the face, not the shared outline @@ -966,6 +1017,57 @@ mod tests { use super::*; use wordcraft_layout::{LayoutCache, LayoutOptions, layout}; + /// #299: the caret's background is the page colour, a highlight, or the dark page. + #[test] + fn background_at_caret_follows_page_colour_highlight_and_dark_mode() { + use wordcraft_doc::Pos; + use wordcraft_doc::props::Highlight; + let mut d = Document::from_text("Hello WordCraft"); + let mid = |d: &Document, off: usize| { + let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default()); + let c = l.caret_on(&Pos::body(0, off), 0).unwrap(); + let page = l.pages[c.page].clone(); + (page, c.x, c.top + c.height / 2.0) + }; + let light = RenderOptions::default(); + let dark = RenderOptions { dark: true, ..Default::default() }; + let (page, x, y) = mid(&d, 2); + assert_eq!(background_at(&d, &page, &light, x, y), Rgb::WHITE); + assert_eq!(background_at(&d, &page, &dark, x, y), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER)); + assert_eq!(background_at(&d, &page, &light, f32::NAN, f32::INFINITY), Rgb::WHITE, "junk points fall back to the paper"); + d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40)); + assert_eq!(background_at(&d, &page, &light, x, y), Rgb(0x10, 0x10, 0x40)); + d.settings.page_color = None; + d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.highlight = Some(Highlight::Black)).unwrap(); + let (page, x, y) = mid(&d, 2); + assert_eq!(background_at(&d, &page, &light, x, y), Rgb::BLACK, "inside the highlight"); + let (page, x, y) = mid(&d, 9); + assert_eq!(background_at(&d, &page, &light, x, y), Rgb::WHITE, "past the highlight"); + } + + /// #299: a filled shape colours the caret's background only inside its outline: an + /// ellipse's corner shows what lies beneath, its centre the fill. + #[test] + fn background_follows_a_shapes_outline_not_its_box() { + let opts = RenderOptions::default(); + let ellipse = Draw::Shape { + rect: wordcraft_geom::Rect::new(100.0, 100.0, 200.0, 100.0), + kind: ShapeKind::Ellipse, + fill: Some(Rgb::BLACK), + stroke: None, + stroke_width: 0.0, + effects: Default::default(), + }; + let at = |x: f32, y: f32| { + let mut bg = [255.0; 3]; + blend_backgrounds(std::slice::from_ref(&ellipse), wordcraft_geom::Point::new(x, y), &opts, &mut bg, 0); + bg + }; + assert_eq!(at(200.0, 150.0), [0.0; 3], "the centre is the fill"); + assert_eq!(at(103.0, 103.0), [255.0; 3], "the box's corner is outside the ellipse"); + assert_eq!(at(350.0, 150.0), [255.0; 3], "outside the box"); + } + #[test] fn renders_text_dark_pixels() { let d = Document::from_text("Hello WordCraft"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0b1ba01a0..10b569dac 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,6 +53,8 @@ pub struct CanvasState { /// An animated column-turn destination; intermediate offsets are allowed during the motion. read_target_page: Option, pub caret_visible_since: f64, + /// The caret's colour and the fingerprint of where it was worked out ([`caret_color_at`]). + caret_bg: Option<(u64, Color32)>, dragging: bool, /// Alt+drag: where the column (block) selection started (page, x, y) and its last end. column_drag: Option<((usize, f32, f32), (usize, f32, f32))>, @@ -162,6 +164,7 @@ impl Default for CanvasState { read_first_visible: None, read_target_page: None, caret_visible_since: 0.0, + caret_bg: None, dragging: false, column_drag: None, last_press: None, @@ -735,9 +738,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.read_target_page = None; app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0)); } - // View › Switch Modes: dark paper, inverted document colours, a white caret. + // View › Switch Modes: dark paper, inverted document colours. let dark_page = app.session.view.dark_mode; - let caret_color = if dark_page { Color32::WHITE } else { t.caret }; let dark_paper = wordcraft_render::DARK_PAPER; let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode; let (hruler, vruler, area) = if show_ruler { @@ -938,18 +940,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // Render before placing the page: a page re-rendered in this frame (a zoom step, an // edit) then sits on the pixel grid in this frame too, rather than a frame later. if visible && !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { - let mut opts = screen_render_options(); - opts.display.marks = app.session.view.marks; - opts.display.always = app.session.prefs.marks; - opts.display.placeholders = true; - opts.display.markup = app.session.view.show_markup; - // Ink Replay draws the ink itself, stroke by stroke, over pages without it. - opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay; - opts.display.revisions = app.session.prefs.markup.clone(); - opts.dark = dark_page; - opts.dark_paper = dark_paper; - opts.display.dim_header = !dim_body; - opts.display.dim_body = dim_body; + let opts = page_render_options(app, dim_body); let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts); let px = img.to_straight(); let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px); @@ -1141,7 +1132,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let on = ((since / 530.0) as u64).is_multiple_of(2); let look = crate::caret_glide::look(app, ui, content_origin, &geo, [pos2(x, y0), pos2(x, y1)], focused, since, on); if look.opacity > 0.0 && focused { - painter.line_segment(look.bar, Stroke::new(1.5, t.caret.gamma_multiply(look.opacity))); + let caret_color = caret_color_at(app, &layout, pi, ex + slot.x, base - slot.y + (slot.d - slot.a) / 2.0, view, dim_body); + painter.line_segment(look.bar, Stroke::new(1.5, caret_color.gamma_multiply(look.opacity))); } if focused { ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530))); @@ -1167,6 +1159,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let y1 = y0 + c.height * scale; let since = crate::now_ms() - app.canvas.caret_visible_since; let on = ((since / 530.0) as u64).is_multiple_of(2); + // The caret contrasts with what lies under it, like Word's inverted caret (#299): + // worked out only when something is drawn in it. + let caret_color = |app: &mut WordApp| caret_color_at(app, &layout, c.page, c.x + c.width / 2.0, c.top + c.height / 2.0, view, dim_body); let bar = if c.width > 0.0 { // Turned text (a table cell's text direction): the caret lies across the page. [pos2(x, y0.round() + 0.5), pos2(x + c.width * scale, y0.round() + 0.5)] @@ -1177,7 +1172,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let shown = app.session.sel.is_collapsed() && focused; let look = crate::caret_glide::look(app, ui, content_origin, &geo, bar, shown, since, on); if shown && look.opacity > 0.0 { - painter.line_segment(look.bar, Stroke::new(1.5, caret_color.gamma_multiply(look.opacity))); + painter.line_segment(look.bar, Stroke::new(1.5, caret_color(app).gamma_multiply(look.opacity))); } if focused { ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530))); @@ -1204,6 +1199,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::theme::install_fonts_extra(ui.ctx(), app.fonts_hans, app.fonts_system_cjk, &app.canvas.ime_faces); ui.ctx().request_repaint(); } + let caret_color = caret_color(app); let g = painter.text(pos2(x, y1), egui::Align2::LEFT_BOTTOM, &app.canvas.ime_preedit, regular(c.height * scale * 0.8), caret_color); painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, caret_color)); } @@ -2637,6 +2633,55 @@ fn field_showing_code(app: &WordApp, pos: &wordcraft_doc::Pos) -> Option wordcraft_render::RenderOptions { + let mut opts = screen_render_options(); + opts.display.marks = app.session.view.marks; + opts.display.always = app.session.prefs.marks; + opts.display.placeholders = true; + opts.display.markup = app.session.view.show_markup; + // Ink Replay draws the ink itself, stroke by stroke, over pages without it. + opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay; + opts.display.revisions = app.session.prefs.markup.clone(); + opts.dark = app.session.view.dark_mode; + opts.dark_paper = wordcraft_render::DARK_PAPER; + opts.display.dim_header = !dim_body; + opts.display.dim_body = dim_body; + opts +} + +/// The caret colour that shows against `bg`: black or white, whichever contrasts more (WCAG +/// contrast ratio), as Word's caret inverts what lies under it (#299). A white caret on white +/// paper, or a black one on a dark page colour or highlight, would vanish. +pub(crate) fn caret_contrast(bg: wordcraft_doc::props::Rgb) -> Color32 { + let lin = |v: u8| { + let c = v as f32 / 255.0; + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } + }; + let l = 0.2126 * lin(bg.0) + 0.7152 * lin(bg.1) + 0.0722 * lin(bg.2); + // Contrast with black (l + 0.05) / 0.05 against with white 1.05 / (l + 0.05). + if (l + 0.05) * (l + 0.05) >= 0.05 * 1.05 { Color32::BLACK } else { Color32::WHITE } +} + +/// The caret colour at page point (`x`, `y`) of page `page`: [`caret_contrast`] of the paper, +/// shading or highlight there. Cached until the layout, the view or the point changes, so the +/// blink doesn't rebuild the page's draw list every frame. +fn caret_color_at(app: &mut WordApp, layout: &std::sync::Arc, page: usize, x: f32, y: f32, view: u64, dim_body: bool) -> Color32 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (std::sync::Arc::as_ptr(layout) as usize, page, x.to_bits(), y.to_bits(), view).hash(&mut h); + let key = h.finish(); + if let Some((k, c)) = app.canvas.caret_bg + && k == key + { + return c; + } + let Some(p) = layout.pages.get(page) else { return Color32::BLACK }; + let bg = wordcraft_render::background_at(&app.session.doc, p, &page_render_options(app, dim_body), x, y); + let c = caret_contrast(bg); + app.canvas.caret_bg = Some((key, c)); + c +} + /// Render options for page rasters shown on screen: on macOS, text is darkened the way the system /// draws it, so a page looks as it does in other Mac apps (exports never are). pub(crate) fn screen_render_options() -> wordcraft_render::RenderOptions { @@ -2704,6 +2749,59 @@ mod tests { assert!(reading_size(vec2(1440.0, 750.0), PX_PER_PT, Some(612.0)).0 <= 612.0); } + /// #299: the caret is black on light backgrounds and white on dark ones, whatever the UI + /// theme: on white paper, a dark page colour, a dark page (Switch Modes) and a highlight. + #[test] + fn caret_contrasts_with_what_lies_under_it() { + use wordcraft_doc::props::Rgb; + assert_eq!(caret_contrast(Rgb::WHITE), Color32::BLACK); + assert_eq!(caret_contrast(Rgb::BLACK), Color32::WHITE); + assert_eq!(caret_contrast(Rgb(0xFF, 0xFF, 0x00)), Color32::BLACK, "yellow highlight"); + assert_eq!(caret_contrast(Rgb(0x00, 0x00, 0x80)), Color32::WHITE, "dark blue"); + assert_eq!(caret_contrast(Rgb(0x33, 0x33, 0x33)), Color32::WHITE, "dark paper"); + assert_eq!(caret_contrast(Rgb(0xC0, 0xC0, 0xC0)), Color32::BLACK, "light grey"); + + let doc = wordcraft_doc::Document::from_text("Hello WordCraft"); + let mut a = WordApp::new(wordcraft_engine::Session::new(doc), Default::default()); + let at = |a: &mut WordApp, off: usize| { + let layout = a.session.layout(); + let c = layout.caret_on(&Pos::body(0, off), 0).unwrap(); + let view = view_key(a, false); + caret_color_at(a, &layout, c.page, c.x, c.top + c.height / 2.0, view, false) + }; + assert_eq!(at(&mut a, 2), Color32::BLACK, "white paper"); + a.run("design.pageColor", json!({"color": "#102040"})).unwrap(); + assert_eq!(at(&mut a, 2), Color32::WHITE, "dark page colour"); + a.run("design.pageColor", json!({})).unwrap(); + assert_eq!(at(&mut a, 2), Color32::BLACK, "page colour removed"); + a.session.view.dark_mode = true; + assert_eq!(at(&mut a, 2), Color32::WHITE, "dark page"); + a.session.view.dark_mode = false; + a.run("select.text", json!({"text": "Hello"})).unwrap(); + a.run("format.highlight", json!({"color": "black"})).unwrap(); + assert_eq!(at(&mut a, 2), Color32::WHITE, "inside a black highlight"); + assert_eq!(at(&mut a, 9), Color32::BLACK, "past it"); + assert_eq!(at(&mut a, 2), Color32::WHITE, "again, from the cache"); + } + + /// #299: the caret's colour is worked out only when the caret is drawn: with text selected + /// there is no caret, so the page's draw list isn't built for it. + #[test] + fn caret_colour_is_not_worked_out_while_the_caret_is_hidden() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello WordCraft")), Default::default()); + a.run("select.text", json!({"text": "Hello"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + assert!(h.state().canvas.caret_bg.is_none(), "no caret, no colour lookup"); + } + /// #167: dragging a ruler's margin edge sets that margin, snapped, never negative, and /// always leaving an inch of text. #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 86d9f89a1..ac2ee8b35 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -226,7 +226,6 @@ pub struct Tokens { pub selection: Color32, /// Find › Reading Highlight. pub find_highlight: Color32, - pub caret: Color32, pub page_shadow: Color32, /// Selection-frame handles (pictures, shapes, text boxes) and their shadow. pub handle: Color32, @@ -268,7 +267,6 @@ impl Tokens { status_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0), selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48), find_highlight: Color32::from_rgba_unmultiplied(0xFF, 0xE0, 0x3D, 0x90), - caret: Color32::BLACK, page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22), handle: Color32::WHITE, handle_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x28), @@ -309,7 +307,6 @@ impl Tokens { status_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F), selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55), find_highlight: Color32::from_rgba_unmultiplied(0xC8, 0xA8, 0x00, 0x70), - caret: Color32::BLACK, page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60), handle: Color32::from_rgb(0xF2, 0xF2, 0xF2), handle_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x5A), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b63b4a023..24bbc3b56 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-13 · **Change:** trivial (no document behind the Start screen, #1129; 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-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: the caret contrasts with the paper, page colour, shading or highlight under it, #299; 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 @@ -33,7 +33,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | 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 | 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; a click sets the proofing language) | ✅ | 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 | ✅ 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 | +| 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) | ✅ | ✅ | 90% | — | | Interface size (File › Options › General, 80–200 %, on top of the system display scale; `ui.interfaceSize`) (#475) | ✅ | 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 | @@ -46,6 +46,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | From 5b92d61a0c3bf6dfb69631cca4f22a5dd1541e2f Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:02:52 +0200 Subject: [PATCH 027/199] Clicks on ribbon and status-bar chrome keep typing in the document (#1093) (#1138) egui drops the page's keyboard focus on any click elsewhere. Command buttons handed it back (#686), but gallery tiles, tab names, empty ribbon space, View check boxes and the status bar's items did not, so the caret vanished and typing went nowhere. After a click on background chrome that leaves nothing focused (no text field, dialog, popup, Backstage or comment balloon), the canvas takes focus back, as in Word. Tabs, style tiles and status-bar items now report their name and state to screen readers. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/ui-egui/src/chrome.rs | 12 +-- crates/ui-egui/src/lib.rs | 132 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/previews.rs | 1 + crates/ui-egui/src/ribbon.rs | 1 + docs/ui-parity.md | 3 +- 6 files changed, 146 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fff44b34d..b98d85f87 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: no document behind the Start screen, #1129; 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: clicks on ribbon chrome keep typing in the document, #1093; previously trivial: progress log: 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. @@ -142,6 +142,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet | | 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | | 2026-10-13 | The caret is black or white, whichever shows against what lies under it — the paper, a page colour, a dark page, shading or a highlight — whatever the UI theme, as Word's inverted caret does; before, it stayed black on dark page colours and highlights and could vanish (#299) | +| 2026-10-13 | Clicking the ribbon's tab names, empty space, gallery tiles or View check boxes, or the status bar's view buttons and language menu, no longer makes the caret vanish: typing goes on in the document as in Word, without another click on the page (#1093) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -286,6 +287,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | | 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves | | 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) | +| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index acfff721c..7e7483a9e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -374,6 +374,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { let w = text_width(ui.painter(), s, &font, track, t.text_dim); let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click()); paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s)); resp }; if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())])) @@ -455,10 +456,10 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { ui.add_space(10.0); let mode = app.session.view.mode; let read = app.session.view.read_mode; - for (icon, id, on) in [ - ("webLayout", "view.webLayout", mode == wordcraft_layout::ViewMode::Web), - ("printLayout", "view.printLayout", mode == wordcraft_layout::ViewMode::Print && !read), - ("readMode", "view.readMode", read), + for (icon, id, name, on) in [ + ("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web), + ("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read), + ("readMode", "view.readMode", tl!("Read Mode"), read), ] { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click()); if on { @@ -467,7 +468,8 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { ui.painter().rect_filled(r, 3.0, t.hover); } icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent); - if resp.on_hover_text(id.trim_start_matches("view.")).clicked() { + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, name)); + if resp.on_hover_text(name).clicked() { let _ = app.run(id, json!({})); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index babaa0cf3..52810c20a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1563,6 +1563,7 @@ impl WordApp { read_aloud::show(self, &ctx); crate::keytips::show(self, &ctx, ui); keys::global_shortcuts(self, &ctx); + self.refocus_after_chrome_click(&ctx); if let Some(url) = self.canvas.open_url.take() { ctx.open_url(egui::OpenUrl::new_tab(url)); } @@ -1580,6 +1581,32 @@ impl WordApp { self.report_dirty(); } + /// A click on the window's chrome (ribbon tabs and empty ribbon space, galleries, check + /// boxes, the status bar, panes) hands keyboard focus back to the document, as in Word, so the + /// caret keeps blinking and typing continues where it was (#1093). egui drops the page's focus + /// on any click elsewhere; only a click that left nothing focused counts, so a text field + /// (font size, zoom, command search) keeps the focus it took, and clicks on a dialog, menu or + /// popup, or while one is open, leave focus alone. Clicks on the page are the canvas's own. + fn refocus_after_chrome_click(&mut self, ctx: &egui::Context) { + if self.ui.backstage || self.dialog.is_some() || self.colors.is_some() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + if self.file_browser.open.is_some() { + return; + } + let Some(pos) = ctx.input(|i| if i.pointer.any_click() { i.pointer.interact_pos() } else { None }) else { return }; + // The page handles its own clicks (comment balloons there take the keyboard themselves). + if self.canvas.balloon.is_some() || self.canvas.viewport.is_some_and(|v| v.contains(pos)) { + return; + } + let on_chrome = ctx.layer_id_at(pos).is_none_or(|l| l.order == egui::Order::Background); + if on_chrome && ctx.memory(|m| m.focused().is_none()) && !egui::Popup::is_any_open(ctx) { + self.canvas.want_focus = true; + ctx.request_repaint(); + } + } + /// Files that arrived asynchronously (web picker, drops): documents open, pictures insert, /// recipient lists (CSV/TSV, spreadsheets, or a text file asked for as one) load as recipients. fn drain_inbox(&mut self) { @@ -3347,6 +3374,111 @@ mod tests { assert!(body_text(h.state()).ends_with(" bold"), "Bold: typing continues in the document"); } + /// Types `text` after a click on `what` and checks it reached the document (#1093). + fn type_after_click(h: &mut egui_kittest::Harness<'_, WordApp>, what: &str, text: &str) { + h.step(); + h.event(egui::Event::Text(text.into())); + h.step(); + assert!(h.state().canvas.focused, "{what}: the page keeps keyboard focus"); + assert!(body_text(h.state()).ends_with(text), "{what}: typing continues in the document"); + } + + /// #1093: clicks on ribbon and status-bar chrome that isn't a command button (gallery tiles, + /// tab names, empty ribbon space, View check boxes, the status bar's view buttons and + /// language menu) used to take keyboard focus from the page: the caret vanished and typing + /// went nowhere until the page was clicked again. + #[test] + fn clicks_on_ribbon_and_status_bar_chrome_keep_typing_in_the_document() { + use egui::accesskit::Role; + + 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); + }, + typed(), + ); + for _ in 0..6 { + h.step(); + } + assert!(h.state().canvas.focused, "the page starts with keyboard focus"); + + // A Styles gallery tile. + h.get_by_role_and_label(Role::Button, "Heading 1").click(); + type_after_click(&mut h, "Heading 1 tile", " gallery"); + let para = h.state().session.doc.para_at(&h.state().session.sel.focus).expect("caret paragraph"); + assert_eq!(para.props.style.as_deref(), Some("Heading1"), "the tile applied its style"); + + // A ribbon tab name, then a View tab check box. + h.get_by_role_and_label(Role::Button, "View").click(); + type_after_click(&mut h, "View tab", " tab"); + assert_eq!(h.state().ui.tab, "View"); + let ruler = h.state().session.view.ruler; + h.get_by_role_and_label(Role::CheckBox, "Ruler").click(); + type_after_click(&mut h, "Ruler check box", " ruler"); + assert_ne!(h.state().session.view.ruler, ruler, "the check box toggled the ruler"); + + // Empty space at the right end of the ribbon, under the tab strip. + let tab = h.get_by_role_and_label(Role::Button, "View").rect(); + let blank = egui::pos2(1425.0, tab.max.y + 25.0); + for pressed in [true, false] { + h.hover_at(blank); + h.event(egui::Event::PointerButton { pos: blank, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + type_after_click(&mut h, "empty ribbon space", " blank"); + + // The status bar's view buttons. + h.get_by_role_and_label(Role::Button, "Web Layout").click(); + type_after_click(&mut h, "Web Layout", " web"); + assert_eq!(h.state().session.view.mode, wordcraft_layout::ViewMode::Web); + h.get_by_role_and_label(Role::Button, "Print Layout").click(); + type_after_click(&mut h, "Print Layout", " print"); + assert_eq!(h.state().session.view.mode, wordcraft_layout::ViewMode::Print); + + // The language item opens its menu; a click elsewhere on the status bar cancels it. + let lang_label = crate::ribbon::caret_language(h.state()); + let lang = h.get_by_label(&lang_label).rect(); + h.get_by_label(&lang_label).click(); + h.step(); + h.step(); + assert!(egui::Popup::is_any_open(&h.ctx), "the language menu opened"); + let elsewhere = egui::pos2(lang.max.x + 40.0, lang.center().y); + for pressed in [true, false] { + h.hover_at(elsewhere); + h.event(egui::Event::PointerButton { pos: elsewhere, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.step(); + assert!(!egui::Popup::is_any_open(&h.ctx), "the click closed the language menu"); + type_after_click(&mut h, "language menu cancelled", " lang"); + + // Back on Home, a text field still takes the focus: the font size box. + h.get_by_role_and_label(Role::Button, "Home").click(); + type_after_click(&mut h, "Home tab", " home"); + let before = body_text(h.state()); + let size_box = h + .get_all_by_role(Role::TextInput) + .find(|n| n.value().is_some_and(|v| v.trim().parse::().is_ok())) + .expect("the font size box") + .rect(); + for pressed in [true, false] { + h.hover_at(size_box.center()); + h.event(egui::Event::PointerButton { + pos: size_box.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }); + } + h.step(); + h.step(); + assert!(!h.state().canvas.focused, "the font size box took keyboard focus"); + h.event(egui::Event::Text("4".into())); + h.step(); + assert!(!h.state().canvas.focused, "the font size box keeps focus while edited"); + assert_eq!(body_text(h.state()), before, "typing went into the font size box, not the page"); + } + /// #330: Word's shortcuts work from the keyboard — Ctrl+Alt+1 (⌘⌥1) applies Heading 1, and /// an Alt shortcut doesn't also type the character its key would produce. #[test] diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index e5a79ee84..548c08a8b 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -377,6 +377,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let r = Rect::from_min_size(pos2(area.min.x + 3.0 + k as f32 * 76.0, area.min.y + 3.0), vec2(74.0, area.height() - 6.0)); let resp = ui.interact(r, ui.id().with(("style_tile", id)), Sense::click()); let active = *id == current; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, name)); if active { ui.painter().rect(r, 3.0, t.checked, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } else if resp.hovered() { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 11ab67442..6eae2b71f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -216,6 +216,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0)); ui.painter().rect_filled(u, 2.0, t.accent); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, shown)); if resp.clicked() { let _ = app.run("ui.tab", json!({"tab": tab})); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 24bbc3b56..5eee00a77 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-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: the caret contrasts with the paper, page colour, shading or highlight under it, #299; 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-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: clicks on ribbon and status-bar chrome keep typing in the document, #1093; 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 @@ -47,6 +47,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | +| 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | From 2dcde62220cb3562aaf1db704a495af88d8df1c5 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:03:12 +0200 Subject: [PATCH 028/199] A dragged shape anchors in the column it lands in, so that column's text wraps around it (#884) (#1139) * #884: a dragged shape anchors in the column it lands in, so that column's text wraps around it arrange.bounds (the canvas drag) re-anchored a moved object to the paragraph under its top edge but ignored x: on a multi-column page it took the last paragraph starting above that y in any column, usually one in the last column. Layout places a float when it lays out its anchor, so text in an earlier column (already laid out) ran through a shape dropped there. anchor_paragraph now picks the paragraph in the column under the object's centre (the ink placement uses the first stroke point). Co-Authored-By: Claude Opus 5.5 * #884: a dropped object finds its column from the section's column geometry Columns came from the distinct left edges of the page's text, so a paragraph shifted by mirrored indents on an even page made a column of its own and the drop anchored to the wrong paragraph. The columns now come from the section's column boxes on the page. Notes in the layout parity doc that lines a paragraph continues into the next column don't wrap yet. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/draw.rs | 10 +- crates/engine/src/cmd/objects.rs | 161 +++++++++++++++++++++++++++++-- docs/layout-parity.md | 5 +- 4 files changed, 164 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b98d85f87..1cccc17d2 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: clicks on ribbon chrome keep typing in the document, #1093; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: dragged shapes wrap in their new column, #884; previously trivial: progress log: 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. @@ -143,6 +143,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing | | 2026-10-13 | The caret is black or white, whichever shows against what lies under it — the paper, a page colour, a dark page, shading or a highlight — whatever the UI theme, as Word's inverted caret does; before, it stayed black on dark page colours and highlights and could vanish (#299) | | 2026-10-13 | Clicking the ribbon's tab names, empty space, gallery tiles or View check boxes, or the status bar's view buttons and language menu, no longer makes the caret vanish: typing goes on in the document as in Word, without another click on the page (#1093) | +| 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -288,6 +289,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves | | 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) | | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | +| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index e9cd535c0..8ba38e5c9 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -460,20 +460,20 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { effects: Default::default(), extra: Default::default(), }; - let at = place(s, &layout, obj, page, (fx, fy), first[1])?; + 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})) } /// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph -/// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the +/// under `anchor` (positioned from its column and top), or, where no paragraph starts on the /// page, to the nearest line and positioned on the page. Returns where its character went. -fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result { - let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y, StoryRef::Body) { +fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor: (f32, f32)) -> Result { + let (at, on_page) = match super::objects::anchor_paragraph(&s.doc, layout, page, anchor, StoryRef::Body) { Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false), None => ( - super::objects::nearest_line(layout, page, anchor_y, StoryRef::Body) + super::objects::nearest_line(layout, page, anchor.1, StoryRef::Body) .ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true, ), diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 1bf3e8a6f..3b1d452cf 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -701,7 +701,8 @@ fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, y: f32, (w, // Keep a corner of it on the page. let x = x.max(12.0 - w).min(pg.w - 12.0); let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0); - let (anchor, on_page) = match anchor_paragraph(&layout, page, y, story) { + // The paragraph under the middle of its top edge: in the column it mostly lands in. + let (anchor, on_page) = match anchor_paragraph(&s.doc, &layout, page, (x + w / 2.0, y), story) { Some(path) => (Pos { story, path, off: 0 }, false), None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true), }; @@ -801,22 +802,50 @@ fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult { Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)})) } -/// The top-level paragraph of `story` under `y` on `page`: the last one starting at or above -/// it (else the first starting on the page). Body paragraphs come from the page's items; -/// header/footer ones from its header and footer. -pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option { +/// The top-level paragraph of `story` under `(x, y)` on `page`: in the column under `x` (from +/// the section's column geometry), the last one starting at or above `y` (else the first +/// starting in that column; else, with no paragraph starting in that column, the last starting +/// at or above `y` anywhere on the page, else the first). Body paragraphs come from the page's +/// items; header/footer ones from its header and footer. The column matters (#884): layout +/// places a floating object when it lays out its anchor, so text laid out before that (an +/// earlier column) can't wrap around it. (Nor do lines continuing a paragraph from the column +/// before: layout breaks them once, in the column the paragraph starts in.) +pub(crate) fn anchor_paragraph( + doc: &wordcraft_doc::Document, + layout: &wordcraft_layout::DocLayout, + page: usize, + (x, y): (f32, f32), + story: StoryRef, +) -> Option { let pg = layout.pages.get(page)?; let items: Box + '_> = match story { StoryRef::Body => Box::new(pg.items.iter()), StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())), }; - let starts: Vec<(&wordcraft_doc::Path, f32)> = items + // Top-level text on the page: (path, left, top, starts the paragraph). + let pieces: Vec<(&wordcraft_doc::Path, f32, f32, bool)> = items .filter_map(|it| match it { - wordcraft_layout::Placed::Lines { story: s, path, l0: 0, y: top, .. } if *s == story && path.depth() == 0 => Some((path, *top)), + wordcraft_layout::Placed::Lines { story: s, path, l0, x, y: top, .. } if *s == story && path.depth() == 0 => { + Some((path, *x, *top, *l0 == 0)) + } _ => None, }) .collect(); - starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone()) + let starts: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3).map(|p| (p.0, p.2)).collect(); + // The page's columns (left, right), from its section's column geometry (headers and footers + // span the text area); the column under `x` is the rightmost one starting at or left of it. + let boxes = match story { + StoryRef::Body => doc.sections().get(pg.section).map(|(_, sp)| sp.column_boxes()).unwrap_or_default(), + StoryRef::Part(_) => Vec::new(), + }; + let mut cols: Vec<(f32, f32)> = + boxes.iter().map(|(cx, w)| (pg.body.x + cx, pg.body.x + cx + w)).filter(|c| c.0.is_finite() && c.1.is_finite()).collect(); + cols.sort_by(|a, b| a.0.total_cmp(&b.0)); + let col_of = |px: f32| cols.iter().rposition(|c| c.0 <= px + 0.5).unwrap_or(0); + let col = col_of(x); + let in_col: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3 && col_of(p.1) == col).map(|p| (p.0, p.2)).collect(); + let pick = |v: &[(&wordcraft_doc::Path, f32)]| v.iter().rev().find(|(_, top)| *top <= y + 0.5).or(v.first()).map(|(p, _)| (*p).clone()); + pick(&in_col).or_else(|| pick(&starts)) } /// The start of the top-level line of `story` on `page` nearest to `y`. @@ -1810,4 +1839,120 @@ mod tests { let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap(); assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), None)); } + + /// The lines of `page` beside `r` vertically (top above its bottom, bottom below its top). + fn lines_beside(s: &mut Session, page: usize, r: wordcraft_geom::Rect) -> Vec<(f32, f32, f32)> { + let layout = s.layout(); + wordcraft_layout::hit::page_lines(&layout.pages[page], StoryRef::Body) + .iter() + .filter(|l| l.top < r.bottom() - 0.5 && l.bottom > r.y + 0.5) + .map(|l| (l.top, l.left, l.right)) + .collect() + } + + /// #884: a Square-wrapped shape dragged across a two-column page: the text in whichever + /// column it lands in wraps around it there, and its old place closes up. + #[test] + fn dragged_square_shape_wraps_text_in_its_new_column() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("layout.columns", &json!({"count": 2})).unwrap(); + for i in 0..40 { + if i > 0 { + s.run("text.newParagraph", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": format!("Paragraph {i} has words enough to run over two or three lines of a narrow column.")})) + .unwrap(); + } + s.run("caret.docStart", &json!({})).unwrap(); + s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap(); + s.run("arrange.bounds", &json!({"width": 80, "height": 60})).unwrap(); + let cols: Vec<(f32, f32)> = { + let layout = s.layout(); + let mut v: Vec<(f32, f32)> = + wordcraft_layout::hit::page_lines(&layout.pages[0], StoryRef::Body).iter().map(|l| (l.left, l.right)).collect(); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + v.dedup_by(|a, b| (a.0 - b.0).abs() < 1.0); + v + }; + assert_eq!(cols.len(), 2, "two columns: {cols:?}"); + let mid = |c: (f32, f32)| (c.0 + c.1) / 2.0 - 40.0; + let wrapped = |s: &mut Session, r: wordcraft_geom::Rect| { + let beside = lines_beside(s, 0, r); + assert!(!beside.is_empty(), "text beside {r:?}"); + for (top, left, right) in beside { + assert!(right <= r.x + 0.5 || left >= r.right() - 0.5, "line at {top} ({left}..{right}) runs into {r:?}"); + } + }; + let full = |s: &mut Session, r: wordcraft_geom::Rect, c: (f32, f32)| { + for (top, left, right) in lines_beside(s, 0, r) { + if left < c.1 && right > c.0 { + assert!((left - c.0).abs() < 0.5 && (right - c.1).abs() < 0.5, "line at {top} ({left}..{right}) still kept clear in {c:?}"); + } + } + }; + // Into the second column, then back into the first, then again to the second, lower. + for (col, y, from) in [(1, 300.0, None), (0, 200.0, Some(1)), (1, 400.0, Some(0))] { + let old = object_area(&mut s).rect; + s.run("arrange.bounds", &json!({"page": 0, "x": mid(cols[col]), "y": y})).unwrap(); + let o = object_area(&mut s); + assert_eq!((o.page, o.rect.x, o.rect.y), (0, mid(cols[col]), y), "dropped where asked"); + wrapped(&mut s, o.rect); + if let Some(c) = from { + full(&mut s, old, cols[c]); + } + } + } + + /// Two columns of `n` paragraphs. + fn two_column_doc(n: usize) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("layout.columns", &json!({"count": 2})).unwrap(); + for i in 0..n { + if i > 0 { + s.run("text.newParagraph", &json!({})).unwrap(); + } + let text = format!("Paragraph {i} has words enough to run over several lines of a narrow column, and then some more words. "); + s.run("text.insert", &json!({"text": text.repeat(3)})).unwrap(); + } + s + } + + /// The page's columns (left, right) from its section's geometry. + fn page_columns(s: &mut Session, page: usize) -> Vec<(f32, f32)> { + let layout = s.layout(); + let pg = &layout.pages[page]; + let boxes = s.doc.sections()[pg.section].1.column_boxes(); + boxes.iter().map(|(x, w)| (pg.body.x + x, pg.body.x + x + w)).collect() + } + + /// #884 review: mirrored indents shift some paragraphs on even pages; that shift isn't taken + /// for a column of its own, so the drop anchors to the paragraph under it, mirrored or not. + #[test] + fn mirrored_indents_make_no_extra_column() { + let mut s = two_column_doc(40); + for i in (1..40).step_by(2) { + let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap(); + p.props.mirror_indents = Some(true); + p.props.indent_right = Some(36.0); + p.touch(); + } + s.touch(); + assert!(s.layout().pages.len() > 1); + let cols = page_columns(&mut s, 1); + // On page 2, a plain paragraph starting in the first column below a mirrored one. + let layout = s.layout(); + let starts: Vec<(usize, f32, f32)> = layout.pages[1] + .items + .iter() + .filter_map(|it| match it { + wordcraft_layout::Placed::Lines { path, l0: 0, x, y, .. } if path.depth() == 0 && *x < cols[1].0 - 0.5 => Some((path.last(), *x, *y)), + _ => None, + }) + .collect(); + assert!(starts.iter().any(|p| p.1 > cols[0].0 + 1.0), "mirrored paragraphs shift right: {starts:?}"); + let &(plain, _, top) = starts.iter().skip(1).find(|p| p.0 % 2 == 0).unwrap(); + let mid = (cols[0].0 + cols[0].1) / 2.0; + let got = anchor_paragraph(&s.doc, &layout, 1, (mid, top + 1.0), StoryRef::Body); + assert_eq!(got, Some(wordcraft_doc::Path::top(plain))); + } } diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 88305fd39..d4d455e24 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-12 · **Change:** 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-13 · **Change:** trivial (a dragged floating object anchors in the column it lands in, #884; 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, @@ -45,7 +45,7 @@ font can't match Word's metrics. | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | -| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | — | +| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 | | Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | @@ -79,6 +79,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | From 85a3a53a8ef94ce2d65dbc89522100a62b88006b Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:03:30 +0200 Subject: [PATCH 029/199] Keep a paragraph's space after clear of the footnote separator (#1116) (#1140) * #1116: keep a paragraph's space after clear of the footnote separator On a page with footnotes (already there or brought by the line), a paragraph's last line only fits if its space after fits above the notes area too; otherwise it goes to the next page with its note (and, with widow control, one more line). Contextual spacing and automatic spacing between list items drop the requirement; pages without notes are unchanged (space after may still run past the bottom margin). Co-Authored-By: Claude Opus 5.5 * #1116: space after vs footnote separator uses the spacing code's tests, only on pages with notes Contextual spacing drops a paragraph's space after only when that paragraph asks for it, and the next paragraph counts as a list item only when it gets a label (Numbering::shows_label), as in the spacing between paragraphs. The kept space after is worked out only for a last line on a page with notes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 +- crates/doc/src/numbering.rs | 5 ++ crates/layout/src/lib.rs | 47 ++++++++++++- crates/layout/src/tests.rs | 129 ++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +- 5 files changed, 184 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1cccc17d2..fdf508fee 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: dragged shapes wrap in their new column, #884; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: footnote pagination, #1116; 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. @@ -144,6 +144,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | The caret is black or white, whichever shows against what lies under it — the paper, a page colour, a dark page, shading or a highlight — whatever the UI theme, as Word's inverted caret does; before, it stayed black on dark page colours and highlights and could vanish (#299) | | 2026-10-13 | Clicking the ribbon's tab names, empty space, gallery tiles or View check boxes, or the status bar's view buttons and language menu, no longer makes the caret vanish: typing goes on in the document as in Word, without another click on the page (#1093) | | 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) | +| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index 655937da3..052893450 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -208,6 +208,11 @@ impl Numbering { let n = self.num(num)?; self.abstracts.iter().find(|a| a.id == n.abstract_id) } + /// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives + /// one), without counting it. + pub fn shows_label(&self, num: u32, level: u8) -> bool { + self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None) + } pub fn level(&self, num: u32, level: u8) -> Option<&Level> { if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) { return Some(l); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 727cff1fe..883011b10 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -660,6 +660,14 @@ impl Ctx<'_> { /// The list label of `p`, advancing the list counters: call it once per paragraph, in /// document order (only for paragraphs that will show). + /// Whether `p` gets a list label ([`Ctx::next_label`] is `Some`), without counting it. + fn has_label(&self, p: &Paragraph) -> bool { + p.props + .numbering + .or_else(|| self.doc.styles.resolve_para(&p.props).numbering) + .filter(|n| n.num != 0) + .is_some_and(|n| self.doc.numbering.shows_label(n.num, n.level)) + } fn next_label(&mut self, p: &Paragraph) -> Option<(String, Level)> { let n = p.props.numbering.or_else(|| self.doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0)?; self.counters.next_label(&self.doc.numbering, n.num, n.level) @@ -2777,6 +2785,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: anchor_y = y0 - applied_before; } let mut n = pl.lines.len(); + // Worked out only for a last line on a page with notes. + let mut after_on_page: Option = None; let mut l0 = 0; while l0 < n { // How many lines fit? @@ -2815,6 +2825,17 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: - if pb.notes.is_empty() && !new_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 } - extra; let bottom = pb.y + lead + (l.top + l.height - first.top); + // On a page with footnotes the paragraph's last line keeps its space after clear of + // the separator (Word): if the space doesn't fit, the line goes to the next page (and, + // with widow control, one more with it). Without notes the space after may run past + // the bottom margin as usual. + let has_notes = !pb.notes.is_empty() || !new_notes.is_empty() || !line_notes.is_empty(); + let tail = if has_notes && l1 + 1 == n { + *after_on_page.get_or_insert_with(|| space_after_kept(ctx, block, &pl.rp, label.is_some())) + } else { + 0.0 + }; + let over = bottom + tail > limit + 0.01; // Notes too long for the rest of the page continue on the next one: the line still // fits if the notes before its last one, and that one's first line, fit below it. // The notes then take the rest of the page. @@ -2824,7 +2845,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let sep = pb.seps.sep_h(pb.notes.first().is_some_and(|n| n.cont)); pb.body_floor().max(bottom) + sep + before + last.first_piece() + pb.seps.notice_h() <= pb.orig_bottom + 0.01 }); - if bottom > limit + 0.01 && l1 > l0 && !split { + if over && l1 > l0 && !split { break; } // A line holding nothing but a page or column break stays at the bottom of the page @@ -2834,7 +2855,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: && p.text .get(l.start..l.stop) .is_some_and(|t| t.chars().all(|c| matches!(c, wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK))); - if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split && !only_break { + if over && l1 == l0 && !pb.at_top() && !split && !only_break { break; } new_notes.extend(line_notes); @@ -3063,6 +3084,28 @@ fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: boo right - left } +/// The space after body paragraph `block` (resolved as `rp`) that is really left below it: none +/// when contextual spacing or automatic spacing between list items drops it before the next +/// paragraph. Same tests as the spacing between paragraphs in `place_para_in_flow`: contextual +/// spacing removes this paragraph's space after only when this one asks for it, and the next +/// counts as a list item when it gets a label. +fn space_after_kept(ctx: &Ctx, block: usize, rp: &wordcraft_doc::resolve::ResolvedPara, listed: bool) -> f32 { + let after = rp.space_after.max(0.0); + if after <= 0.0 { + return 0.0; + } + if let Some(Block::Para(next)) = ctx.doc.body.get(block + 1).map(|b| &**b) { + let nrp = ctx.doc.styles.resolve_para(&next.props); + if nrp.style == rp.style && rp.contextual_spacing { + return 0.0; + } + if listed && rp.after_auto && ctx.has_label(next) { + return 0.0; + } + } + after +} + /// Height of the first line of the block after `block` (for keep-with-next). /// The room the paragraphs after `block` need on its page for keep with next: the next one's /// first line, or all of it and what it keeps with in turn when it is kept with the next too diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index de4b5d19f..afdf9d0e3 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4990,3 +4990,132 @@ fn automatic_spacing_drops_between_list_items() { ); } } + +/// #1116: a document of a top paragraph with `before` points of space before, filler lines, a +/// paragraph of several lines (block `t`) with 6 pt after and, when `note`, a footnote on its last line. +fn space_after_doc(before: f32, note: bool) -> (Document, usize, Option) { + let text = format!("Top.\n{}{}\nAfter.", "Body text line.\n".repeat(14), "Words in a paragraph that wraps. ".repeat(14)); + let mut d = Document::from_text(&text); + let t = 15; + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.props.space_before = Some(before); + } + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(t).map(std::sync::Arc::make_mut) { + p.props.space_after = Some(6.0); + p.props.widow_control = Some(true); + } + let len = d.para(StoryRef::Body, &Path(vec![t as u32])).map(|p| p.text.len()).unwrap(); + let id = note.then(|| footnote(&mut d, &Pos::body(t, len), "The note.")); + (d, t, id) +} + +/// The pieces of body paragraph `t` as (page, l0, l1, bottom), and its line count. +fn para_pieces(l: &DocLayout, t: usize) -> (Vec<(usize, usize, usize, f32)>, usize) { + let mut v = Vec::new(); + let mut n = 0; + for (pi, p) in l.pages.iter().enumerate() { + for it in &p.items { + if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, y, .. } = it + && path.0 == [t as u32] + { + n = para.lines.len(); + v.push((pi, *l0, *l1, item_bottom(*y, para, *l0, *l1).unwrap())); + } + } + } + (v, n) +} + +#[test] +fn space_after_stays_clear_of_the_footnote_separator() { + // #1116, measured in Word: on a page with footnotes a paragraph ending there keeps its space + // after above the separator, or its last lines (two, with widow control) go to the next page + // with the note. + let mut moved = 0; + for before in 0..=300 { + let (d, t, id) = space_after_doc(before as f32, true); + let l = lay(&d); + let (pieces, n) = para_pieces(&l, t); + assert!(n >= 4, "a paragraph of several lines: {n}"); + let &(page, _, l1, bottom) = pieces.last().unwrap(); + assert_eq!(l1, n); + let note = note_pieces(&l, id.unwrap()); + assert_eq!(note.first().map(|p| p.0), Some(page), "before {before}: the note is on its reference's page"); + // The separator's area (default separator: NOTE_SEP high, its rule half way down) sits + // right above the note. + let sep_top = note[0].3 - NOTE_SEP; + assert!(sep_top - bottom >= 6.0 - 0.01, "before {before}: last line ends at {bottom}, the separator starts at {sep_top}"); + if pieces.len() > 1 { + assert_eq!(pieces.len(), 2, "{pieces:?}"); + assert!(pieces[0].2 - pieces[0].1 >= 2 && pieces[1].2 - pieces[1].1 >= 2, "widow/orphan control: {pieces:?}"); + moved += 1; + } + } + assert!(moved > 0, "the sweep crosses the page bottom"); +} + +#[test] +fn space_after_may_overflow_the_bottom_margin_without_notes() { + // Without footnotes nothing changes: the last line only has to fit, its space after may run + // past the bottom margin (Word). + let mut tight = 0; + for before in 0..=300 { + let (d, t, _) = space_after_doc(before as f32, false); + let l = lay(&d); + let bottom_margin = d.last_section.page_h - d.last_section.margin_bottom; + let (pieces, _) = para_pieces(&l, t); + let &(_, _, _, bottom) = pieces.last().unwrap(); + assert!(bottom <= bottom_margin + 0.01, "before {before}: {bottom}"); + if bottom > bottom_margin - 6.0 { + tight += 1; + } + } + assert!(tight > 0, "some step leaves less than the space after below the last line"); +} + +/// #1116: the sweep of `space_after_stays_clear_of_the_footnote_separator` with paragraphs `t` +/// and `t + 1` changed by `tweak`: the last line of `t` stays `need` points clear of the separator. +fn space_after_clear_with(tweak: impl Fn(&mut Document, usize), need: f32) { + for before in 0..=300 { + let (mut d, t, id) = space_after_doc(before as f32, true); + tweak(&mut d, t); + let l = lay(&d); + let (pieces, _) = para_pieces(&l, t); + let &(page, _, _, bottom) = pieces.last().unwrap(); + let note = note_pieces(&l, id.unwrap()); + assert_eq!(note.first().map(|p| p.0), Some(page), "before {before}: the note is on its reference's page"); + let sep_top = note[0].3 - NOTE_SEP; + assert!(sep_top - bottom >= need - 0.01, "before {before}: last line ends at {bottom}, the separator starts at {sep_top}"); + } +} + +fn para_props(d: &mut Document, i: usize) -> &mut ParaProps { + match d.body.get_mut(i).map(std::sync::Arc::make_mut) { + Some(wordcraft_doc::Block::Para(p)) => &mut p.props, + _ => panic!("no paragraph {i}"), + } +} + +#[test] +fn space_after_stays_clear_when_only_the_next_paragraph_has_contextual_spacing() { + // Contextual spacing on the next paragraph alone drops its space before, not this one's space + // after (as the spacing between them): the space after still stays clear of the separator. + space_after_clear_with(|d, t| para_props(d, t + 1).contextual_spacing = Some(true), 6.0); +} + +#[test] +fn space_after_stays_clear_before_a_numbered_paragraph_without_a_label() { + // Automatic spacing drops only between two list items with labels: before a paragraph whose + // list doesn't exist (no label) a list item's automatic space after stays clear. + use wordcraft_doc::props::NumRef; + space_after_clear_with( + |d, t| { + let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); + let p = para_props(d, t); + p.numbering = Some(NumRef { num, level: 0 }); + p.space_after_auto = Some(true); + para_props(d, t + 1).numbering = Some(NumRef { num: 9999, level: 0 }); + }, + wordcraft_doc::resolve::AUTO_SPACING, + ); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index d4d455e24..2a8bc2bf2 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-13 · **Change:** trivial (a dragged floating object anchors in the column it lands in, #884; 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-13 · **Change:** 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, @@ -37,7 +37,7 @@ font can't match Word's metrics. | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852) | 1–2 | | List labels: number formats (decimal, letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `layout::tests::chinese_counting_list_labels_and_their_font_runs`; a label mixing faces (digit + CJK comma) is drawn run by run (#534). Hebrew, Arabic, Hindi, Thai, Vietnamese and Russian formats show decimal | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | -| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | +| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | | Body top below a tall header, footer pushes body up | ✅ | #138 | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138 | — | @@ -80,6 +80,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | +| 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | From e8482fa96418406f7a2597996beb4dd7f7272031 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:04:27 +0200 Subject: [PATCH 030/199] A chosen interface size is absolute; Auto keeps the display scale (#822) (#1142) * #822: a chosen interface size is absolute; Auto keeps the display scale On a display the system reports at 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI), "100%" drew at 200 %. As in PhotoCraft and PdfCraft (pdfcraft#1005), a chosen size now divides the display scale back out, so 100% is one screen pixel per point. The new default, Auto, keeps the display scale and its menu entry shows the size it draws at. Sizes run 75-300 %; ui.json moves to uiScale and migrates the old interfaceSize (100 -> Auto). Co-Authored-By: Claude Opus 5.5 * #822: an old interface size becomes the size it drew at; a valid uiScale wins A pre-#822 interfaceSize was on top of the display scale, so it is kept until the first frame knows that scale and becomes old x native (125% on a 2x display stays at 250%). Note that on the web a chosen size cancels browser zoom. Co-Authored-By: Claude Opus 5.5 * #822: rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 +- crates/ui-egui/src/i18n/bg.tsv | 3 +- crates/ui-egui/src/i18n/cs.tsv | 3 +- crates/ui-egui/src/i18n/de.tsv | 3 +- crates/ui-egui/src/i18n/es.tsv | 3 +- crates/ui-egui/src/i18n/et.tsv | 3 +- crates/ui-egui/src/i18n/fr.tsv | 3 +- crates/ui-egui/src/i18n/hu.tsv | 3 +- crates/ui-egui/src/i18n/it.tsv | 3 +- crates/ui-egui/src/i18n/ja.tsv | 3 +- crates/ui-egui/src/i18n/pl.tsv | 3 +- crates/ui-egui/src/i18n/pt-br.tsv | 3 +- crates/ui-egui/src/i18n/ru.tsv | 3 +- crates/ui-egui/src/i18n/sr-latn.tsv | 3 +- crates/ui-egui/src/i18n/sr.tsv | 3 +- crates/ui-egui/src/i18n/tr.tsv | 3 +- crates/ui-egui/src/i18n/uk.tsv | 3 +- crates/ui-egui/src/i18n/zh-hans.tsv | 3 +- crates/ui-egui/src/i18n/zh-hant.tsv | 3 +- crates/ui-egui/src/lib.rs | 187 +++++++++++++++++++++++----- crates/ui-egui/src/options.rs | 17 ++- crates/ui-egui/src/theme.rs | 85 +++++++++---- docs/control-protocol.md | 4 +- docs/ui-parity.md | 5 +- 24 files changed, 269 insertions(+), 86 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fdf508fee..07e73837e 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: footnote pagination, #1116; 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: absolute interface sizes, #822; previously trivial: progress log: 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. @@ -145,6 +145,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Clicking the ribbon's tab names, empty space, gallery tiles or View check boxes, or the status bar's view buttons and language menu, no longer makes the caret vanish: typing goes on in the document as in Word, without another click on the page (#1093) | | 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) | | 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged | +| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index c7a188717..013fdc1e7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1360,7 +1360,8 @@ Edit Field… Редактиране на поле… Formatted Форматиран Interface size: Размер на интерфейса: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Уголемява или смалява лентата, менютата и диалоговите прозорци в допълнение към мащабирането на дисплея в системата. Мащабът на документа е отделна настройка. + Auto ({percent}%) Автоматично ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Уголемява или смалява лентата, менютата и диалоговите прозорци. „Автоматично“ следва мащабирането на дисплея в системата; 100% е един пиксел от екрана на точка. Мащабът на документа е отделна настройка. In Line with Text На един ред с текста Square Квадрат Through През diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8bff692a2..a7b8f04c4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1354,7 +1354,8 @@ Edit Field… Upravit pole… Formatted Formátováno Interface size: Velikost rozhraní: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Zvětší nebo zmenší pás karet, nabídky a dialogová okna nad rámec škálování displeje v systému. Lupa dokumentu se nastavuje zvlášť. + Auto ({percent}%) Automaticky ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Zvětší nebo zmenší pás karet, nabídky a dialogová okna. Automaticky sleduje škálování displeje v systému; 100% je jeden pixel obrazovky na bod. Lupa dokumentu se nastavuje zvlášť. In Line with Text V řádku s textem Square Čtverec Through Průchozí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3d3733fcf..78c03cae1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1325,7 +1325,8 @@ Flip Horizontal Horizontal spiegeln Formatted Formatiert Interface size: Oberflächengröße: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Vergrößert oder verkleinert Menüband, Menüs und Dialoge, zusätzlich zur Anzeigeskalierung Ihres Systems. Die Dokumentansicht ist davon getrennt. + Auto ({percent}%) Automatisch ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Vergrößert oder verkleinert Menüband, Menüs und Dialoge. „Automatisch“ folgt der Anzeigeskalierung Ihres Systems; 100% ist ein Bildschirmpixel pro Punkt. Die Dokumentansicht ist davon getrennt. Base text: Basistext: Distribute letters Buchstaben verteilen Distribute spacing Abstände verteilen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8cd3e5604..d9dbabb4c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1461,7 +1461,8 @@ Formatted Con formato Deleted Eliminado Interface size: Tamaño de la interfaz: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte. + Auto ({percent}%) Auto ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo. Auto sigue el escalado de pantalla del sistema; 100% es un píxel de pantalla por punto. El zoom del documento es aparte. Resize Table by Dragging Cambiar tamaño de tabla arrastrando Print Markup Imprimir marcas Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentarios junto a cada página y cambios marcados en PDF, impresiones e imágenes; una lista de comentarios al final de páginas web, Markdown y texto. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 85d5ec8ee..94de44c75 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1440,7 +1440,8 @@ Formatted Vormindatud Deleted Kustutatud Interface size: Kasutajaliidese suurus: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi. + Auto ({percent}%) Automaatne ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks. Automaatne järgib süsteemi ekraani skaleerimist; 100% on üks ekraanipiksel punkti kohta. Dokumendi suum on eraldi. Resize Table by Dragging Tabeli suuruse muutmine lohistades Print Markup Prindi märgistus Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Kommentaarid iga lehe kõrval ja jälitatud muudatused PDF-ides, väljatrükkides ja piltides; kommentaaride loend veebilehtede, Markdowni ja teksti lõpus. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 265a7892f..c459e24de 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1355,7 +1355,8 @@ Edit Field… Modifier le champ… Formatted Mis en forme Interface size: Taille de l’interface : - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agrandit ou réduit le ruban, les menus et les boîtes de dialogue en plus de la mise à l’échelle d’affichage de votre système. Le zoom du document est distinct. + Auto ({percent}%) Auto ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Agrandit ou réduit le ruban, les menus et les boîtes de dialogue. Auto suit la mise à l’échelle d’affichage de votre système ; 100% correspond à un pixel d’écran par point. Le zoom du document est distinct. 1. Formatting restrictions 1. Restrictions de mise en forme 2. Editing restrictions 2. Restrictions de modification 3. Start enforcement 3. Démarrer la protection diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8748764df..03baf13f3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1740,7 +1740,8 @@ Formatted Formázott Deleted Törölve Interface size: Felület mérete: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. A menüszalag, a menük és a párbeszédablakok nagyítása vagy kicsinyítése a rendszer kijelzőméretezésén felül. A dokumentum nagyítása ettől független. + Auto ({percent}%) Automatikus ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. A menüszalag, a menük és a párbeszédablakok nagyítása vagy kicsinyítése. Az Automatikus a rendszer kijelzőméretezését követi; 100% pontonként egy képernyőpixel. A dokumentum nagyítása ettől független. Copyright Sign Copyright jel Demote Lefokozás Edit Field Code Mezőkód szerkesztése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8ce06c312..70ae8035b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1331,7 +1331,8 @@ Flip Horizontal Capovolgi orizzontalmente Formatted Formattato Interface size: Dimensioni interfaccia: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Ingrandisce o riduce la barra multifunzione, i menu e le finestre di dialogo, in aggiunta al ridimensionamento dello schermo del sistema. Lo zoom del documento è separato. + Auto ({percent}%) Auto ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Ingrandisce o riduce la barra multifunzione, i menu e le finestre di dialogo. Auto segue il ridimensionamento dello schermo del sistema; 100% è un pixel dello schermo per punto. Lo zoom del documento è separato. Title Titolo Subject Oggetto Keywords Parole chiave diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b198a3dd3..0a82ebf86 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1421,7 +1421,8 @@ Formatted 書式変更 Deleted 削除 Interface size: 表示サイズ: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。 + Auto ({percent}%) 自動 ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. リボン、メニュー、ダイアログを拡大または縮小します。自動はシステムの表示倍率に従います。100% は 1 ポイントあたり画面の 1 ピクセルです。文書のズームは別に設定します。 Resize Table by Dragging ドラッグで表のサイズを変更 Print Markup 変更履歴とコメントを印刷 Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF、印刷、画像では各ページの横にコメントと変更履歴を表示し、Web ページ、Markdown、テキストでは末尾にコメント一覧を付けます。 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 17cb3c2db..fa46d7918 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1253,7 +1253,8 @@ Links and References Łącza i odwołania List Definition Definicja listy Mail Merge Korespondencja seryjna - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Powiększa lub zmniejsza wstążkę, menu i okna dialogowe, niezależnie od skalowania ekranu w systemie. Powiększenie dokumentu ustawia się osobno. + Auto ({percent}%) Automatycznie ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Powiększa lub zmniejsza wstążkę, menu i okna dialogowe. Automatycznie oznacza skalowanie ekranu ustawione w systemie; 100% to jeden piksel ekranu na punkt. Powiększenie dokumentu ustawia się osobno. Model Model Mon Pon More Columns… Więcej kolumn… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3649b3d55..83f870b54 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1433,7 +1433,8 @@ Formatted Formatado Deleted Excluído Interface size: Tamanho da interface: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado. + Auto ({percent}%) Auto ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo. Auto segue a escala de exibição do sistema; 100% é um pixel da tela por ponto. O zoom do documento é separado. Resize Table by Dragging Redimensionar tabela arrastando Print Markup Imprimir marcação Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentários ao lado de cada página e alterações controladas em PDFs, impressões e imagens; uma lista de comentários no final de páginas da web, Markdown e texto. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ef0250799..6b20dcbb3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1722,7 +1722,8 @@ preview Title Пример Formatted Отформатировано Deleted Удалено Interface size: Размер интерфейса: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Увеличивает или уменьшает ленту, меню и диалоговые окна поверх системного масштабирования экрана. Масштаб документа настраивается отдельно. + Auto ({percent}%) Авто ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Увеличивает или уменьшает ленту, меню и диалоговые окна. «Авто» следует системному масштабированию экрана; 100% — один пиксель экрана на точку. Масштаб документа настраивается отдельно. Copyright Sign Знак авторского права Demote Понизить уровень Edit Field Code Изменить код поля diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 5e2e5d546..8dd5be177 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1431,7 +1431,8 @@ Formatted Formatirano Deleted Izbrisano Interface size: Veličina interfejsa: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno. + Auto ({percent}%) Automatski ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge. Automatski prati skaliranje ekrana u sistemu; 100% je jedan piksel ekrana po tački. Zumiranje dokumenta je zasebno. Resize Table by Dragging Promena veličine tabele prevlačenjem Print Markup Štampaj oznake Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Komentari pored svake stranice i praćene izmene u PDF-ovima, otiscima i slikama; lista komentara na kraju veb-stranica, Markdown-a i teksta. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index eca06b5d5..3c35a3166 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1435,7 +1435,8 @@ Formatted Форматирано Deleted Избрисано Interface size: Величина интерфејса: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно. + Auto ({percent}%) Аутоматски ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Повећава или смањује траку, меније и дијалоге. Аутоматски прати скалирање екрана у систему; 100% је један пиксел екрана по тачки. Зумирање документа је засебно. Resize Table by Dragging Промена величине табеле превлачењем Print Markup Штампај ознаке Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментари поред сваке странице и праћене измене у PDF-овима, отисцима и сликама; листа коментара на крају веб-страница, Markdown-а и текста. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8940a1712..32d528ed1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1720,7 +1720,8 @@ preview Title Örnek ✓ None ✓ Yok Formatted Biçimlendirildi Interface size: Arabirim boyutu: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Şeridi, menüleri ve iletişim kutularını sisteminizin ekran ölçeklendirmesine ek olarak büyütür veya küçültür. Belge yakınlaştırması ayrıdır. + Auto ({percent}%) Otomatik (%{percent}) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Şeridi, menüleri ve iletişim kutularını büyütür veya küçültür. Otomatik, sisteminizin ekran ölçeklendirmesini izler; %100, nokta başına bir ekran pikselidir. Belge yakınlaştırması ayrıdır. Deleted Silindi Copyright Sign Telif Hakkı İşareti Demote Düzey Düşür diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 97acd700d..aa1ba4d49 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1433,7 +1433,8 @@ Formatted Відформатовано Deleted Видалено Interface size: Розмір інтерфейсу: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо. + Auto ({percent}%) Авто ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна. «Авто» відповідає системному масштабуванню екрана; 100% — один піксель екрана на точку. Масштаб документа налаштовується окремо. Resize Table by Dragging Змінити розмір таблиці перетягуванням Print Markup Друкувати примітки та виправлення Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментарі біля кожної сторінки й відстежені зміни в PDF, друку та зображеннях; список коментарів наприкінці вебсторінок, Markdown і тексту. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3f75f776f..9eab71385 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1433,7 +1433,8 @@ Formatted 已设置格式 Deleted 已删除 Interface size: 界面大小: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。 + Auto ({percent}%) 自动 ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. 放大或缩小功能区、菜单和对话框。“自动”跟随系统的显示缩放;100% 为每点一个屏幕像素。文档缩放单独设置。 Resize Table by Dragging 拖动调整表格大小 Print Markup 打印标记 Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. 在 PDF、打印和图像中于每页旁显示批注和修订;在网页、Markdown 和文本末尾附上批注列表。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3cd893f09..0b968cc77 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1422,7 +1422,8 @@ Formatted 已設定格式 Deleted 已刪除 Interface size: 介面大小: - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。 + Auto ({percent}%) 自動 ({percent}%) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. 放大或縮小功能區、功能表與對話方塊。「自動」依循系統的顯示縮放;100% 為每點一個螢幕像素。文件縮放另行設定。 Resize Table by Dragging 拖曳調整表格大小 Print Markup 列印標記 Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. 在 PDF、列印和影像中於每頁旁顯示註解和修訂;在網頁、Markdown 和文字末尾附上註解清單。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 52810c20a..2d4a71ed3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -180,9 +180,14 @@ pub struct UiState { pub recent_colors: Vec, /// Interface theme: light, dark, or follow the OS appearance (#115). pub theme: theme::Appearance, - /// Interface size in percent (#475): scales the whole interface on top of the system's - /// display scale. Junk or out-of-range saved values read as 100 % or are clamped to 80–200 %. + /// Interface size (#475, #822): Auto (the display scale the system reports) or an absolute + /// size in percent. Junk saved values read as Auto; out-of-range ones are clamped to 50–300 %. + #[serde(rename = "uiScale")] pub interface_size: theme::InterfaceSize, + /// The interface size `ui.json` held before #822, on top of the display scale: read once to + /// migrate ([`theme::legacy_interface_percent`]), never written. A valid `uiScale` wins. + #[serde(rename = "interfaceSize", skip_serializing)] + pub(crate) legacy_interface_size: Option, /// Animation effects (smooth scrolling, fades): on, off, or follow the system's setting, which /// is the default. An unknown saved value reads as the default. pub animations: theme::Animations, @@ -274,6 +279,7 @@ impl Default for UiState { recent_colors: Vec::new(), theme: theme::Appearance::default(), interface_size: theme::InterfaceSize::default(), + legacy_interface_size: None, animations: theme::Animations::default(), legacy_dark: None, nav_tab: "headings".into(), @@ -391,6 +397,13 @@ pub struct WordApp { fonts_frames: u32, /// The effective interface theme last installed in egui ([`theme::apply`]). applied_theme: Option, + /// The display scale (native pixels per point) seen in the last frame, 1.0 before the first; + /// `ui.interfaceSize` reports the size Auto draws at with it. + native_scale: f32, + /// A pre-#822 interface size (percent on top of the display scale) waiting for the first frame + /// that knows the display's scale, to become the absolute size it drew at + /// ([`theme::migrate_legacy_interface_size`]). + legacy_interface_size: Option, /// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer). desktop_theme: desktop_theme::DesktopTheme, /// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing. @@ -495,6 +508,8 @@ impl WordApp { want_system_cjk: false, fonts_frames: 0, applied_theme: None, + native_scale: 1.0, + legacy_interface_size: None, // Inert until the app binary calls `watch_desktop_theme`. desktop_theme: desktop_theme::DesktopTheme::default(), desktop_dark: None, @@ -580,6 +595,11 @@ impl WordApp { if let Some(dark) = self.ui.legacy_dark.take() { self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light }; } + // A `ui.json` from before #822: its interface size was on top of the display scale, so it + // becomes absolute on the first frame that knows that scale (`logic`). A chosen `uiScale` + // (the current key) wins; "auto" can't be told from a missing key, so the old size does. + self.legacy_interface_size = + self.ui.legacy_interface_size.take().filter(|_| self.ui.interface_size.is_auto()).and_then(|old| theme::legacy_interface_percent(&old)); // The session owns the name from here on; `prefs` copies it back when saving. let author = std::mem::take(&mut self.ui.author); if !author.trim().is_empty() { @@ -1058,21 +1078,31 @@ impl WordApp { json!({"theme": self.ui.theme.code(), "dark": self.ui_is_dark()}) } "ui.interfaceSize" => { - // Interface size in percent, 80–200 (clamped); no value reads it. Document zoom is - // `view.zoom` and stays as it is. + // `auto` (the display scale the system reports) or an absolute size in percent, + // 50–300 (clamped); no value reads it. `percent` in the result is the size the + // interface is drawn at. Document zoom is `view.zoom` and stays as it is. if let Some(v) = p.get("percent").or_else(|| p.get("value")) { - match v.as_f64().and_then(theme::InterfaceSize::from_percent) { - Some(size) => self.ui.interface_size = size, + let size = match v.as_str() { + Some(a) if a.trim().eq_ignore_ascii_case("auto") => Some(theme::InterfaceSize::AUTO), + Some(t) => t.trim().trim_end_matches('%').trim().parse::().ok().and_then(theme::InterfaceSize::from_percent), + None => v.as_f64().and_then(theme::InterfaceSize::from_percent), + }; + match size { + Some(size) => { + self.ui.interface_size = size; + self.legacy_interface_size = None; + } None => { return Some(Err(format!( - "`percent` must be a number from {} to {}", + "`percent` must be \"auto\" or a number from {} to {}", theme::InterfaceSize::MIN, theme::InterfaceSize::MAX ))); } } } - json!({"percent": self.ui.interface_size.percent()}) + let size = self.ui.interface_size; + json!({"auto": size.is_auto(), "percent": size.drawn_percent(self.native_scale)}) } "ui.animations" => { // `system` (follow the system's animation setting), `on` or `off`; no value reads @@ -1365,10 +1395,19 @@ impl WordApp { theme::apply(ctx, effective); self.applied_theme = Some(effective); } - // Interface size (#475): egui's zoom factor multiplies the display scale the system reports - // (native pixels per point), so it adds to it rather than replacing it. Keyboard zoom is - // off (above), so nothing else changes the factor; `set_zoom_factor` repaints only on change. - let zoom = self.ui.interface_size.zoom_factor(); + // Interface size (#475, #822): egui's zoom factor multiplies the display scale the system + // reports (native pixels per point). Auto keeps that scale; a chosen size divides it back + // out, so "100%" is one screen pixel per point even where the system's scale is a guess. + // Recomputed every frame, so a move to a monitor with another scale keeps a chosen size. + // Keyboard zoom is off (above), so nothing else changes the factor; `set_zoom_factor` + // repaints only on change. + self.native_scale = theme::usable_scale(ctx.native_pixels_per_point().unwrap_or(1.0)); + if let Some(native) = ctx.native_pixels_per_point() + && let Some(old) = self.legacy_interface_size.take() + { + self.ui.interface_size = theme::migrate_legacy_interface_size(old, native); + } + let zoom = self.ui.interface_size.zoom_factor(self.native_scale); if ctx.zoom_factor() != zoom { ctx.set_zoom_factor(zoom); } @@ -4097,52 +4136,134 @@ mod tests { assert_eq!((odd.theme, odd.tab.as_str()), (Appearance::Light, "Insert")); } - /// #475: the interface size is kept between runs; junk saved values read as 100 % and + /// #475, #822: the interface size is kept between runs; junk saved values read as Auto and /// out-of-range ones are clamped, without losing the other preferences. #[test] fn interface_size_survives_a_restart_and_clamps_junk() { let mut a = app(); - assert_eq!(a.run("ui.interfaceSize", json!({})).unwrap()["percent"], 100); - assert_eq!(a.run("ui.interfaceSize", json!({"percent": 150})).unwrap()["percent"], 150); + assert_eq!(a.run("ui.interfaceSize", json!({})).unwrap(), json!({"auto": true, "percent": 100})); + assert_eq!(a.run("ui.interfaceSize", json!({"percent": 150})).unwrap(), json!({"auto": false, "percent": 150})); let saved = serde_json::to_string(&a.prefs()).unwrap(); - assert!(saved.contains(r#""interfaceSize":150"#), "{saved}"); + assert!(saved.contains(r#""uiScale":150"#) && !saved.contains("interfaceSize"), "{saved}"); let mut b = app(); b.apply_prefs(serde_json::from_str(&saved).unwrap()); - assert_eq!(b.ui.interface_size.percent(), 150); - for (raw, want) in [("1000", 200), ("10", 80), ("-5", 80), ("124.6", 125), (r#""big""#, 100), ("null", 100), ("[1]", 100)] { - let ui: UiState = serde_json::from_str(&format!(r#"{{"interfaceSize": {raw}, "tab": "Insert"}}"#)).unwrap(); + assert_eq!(b.ui.interface_size.percent(), Some(150)); + for (raw, want) in [ + ("1000", Some(300)), + ("10", Some(50)), + ("-5", Some(50)), + ("124.6", Some(125)), + (r#""auto""#, None), + (r#""big""#, None), + ("null", None), + ("[1]", None), + ] { + let ui: UiState = serde_json::from_str(&format!(r#"{{"uiScale": {raw}, "tab": "Insert"}}"#)).unwrap(); assert_eq!((ui.interface_size.percent(), ui.tab.as_str()), (want, "Insert"), "{raw}"); } - assert_eq!(a.run("ui.interfaceSize", json!({"percent": 500})).unwrap()["percent"], 200); + assert_eq!(a.run("ui.interfaceSize", json!({"percent": 500})).unwrap()["percent"], 300); assert!(a.run("ui.interfaceSize", json!({"percent": "huge"})).is_err()); assert!(a.run("ui.interfaceSize", json!({"percent": null})).is_err()); - assert_eq!(a.ui.interface_size.percent(), 200, "a rejected value keeps the setting"); + assert_eq!(a.ui.interface_size.percent(), Some(300), "a rejected value keeps the setting"); + assert_eq!(a.run("ui.interfaceSize", json!({"value": "125%"})).unwrap()["percent"], 125); + assert_eq!(a.run("ui.interfaceSize", json!({"value": "Auto"})).unwrap()["auto"], true); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + assert!(saved.contains(r#""uiScale":"auto""#), "{saved}"); } - /// #475: the interface size multiplies the display scale the system reports (egui's zoom - /// factor) and leaves document zoom alone. + /// #822: a `ui.json` from before saved `interfaceSize` on top of the display scale. Its + /// default, 100, becomes Auto (the same size as before); a size picked on purpose becomes the + /// absolute size it drew at, once the first frame says the display's scale: 125 % on a 2× + /// display drew at 250 % and stays there. A valid `uiScale` beside it wins. #[test] - fn interface_size_scales_on_top_of_the_system_scale() { + fn interface_size_from_before_822_migrates() { + let frame = |a: &mut WordApp, native: f32| { + let ctx = egui::Context::default(); + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native); + ctx.run_ui(input, |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); + }; + for (json, native, want) in [ + (r#"{"interfaceSize": 100}"#, 2.0, None), + (r#"{"interfaceSize": 100.2}"#, 2.0, None), + (r#"{"interfaceSize": "big"}"#, 2.0, None), + (r#"{"interfaceSize": 125}"#, 2.0, Some(250)), + (r#"{"interfaceSize": 150}"#, 1.0, Some(150)), + (r#"{"interfaceSize": 80}"#, 1.5, Some(120)), + (r#"{"interfaceSize": 200}"#, 2.0, Some(300)), + (r#"{"interfaceSize": 80}"#, 0.5, Some(50)), + (r#"{"interfaceSize": 150, "uiScale": 90}"#, 2.0, Some(90)), + (r#"{"interfaceSize": 150, "uiScale": "junk"}"#, 2.0, Some(300)), + ] { + let mut a = app(); + a.apply_prefs(serde_json::from_str(json).unwrap()); + frame(&mut a, native); + assert_eq!(a.ui.interface_size.percent(), want, "{json} on a {native}× display"); + assert!(!serde_json::to_string(&a.prefs()).unwrap().contains("interfaceSize"), "{json}"); + } + // Converted once: a later move to another display keeps the absolute size. + let mut a = app(); + a.apply_prefs(serde_json::from_str(r#"{"interfaceSize": 125}"#).unwrap()); + frame(&mut a, 2.0); + frame(&mut a, 1.0); + assert_eq!(a.ui.interface_size.percent(), Some(250)); + // A size the user picks before the first frame isn't overwritten by the old one. + let mut a = app(); + a.apply_prefs(serde_json::from_str(r#"{"interfaceSize": 125}"#).unwrap()); + a.run("ui.interfaceSize", json!({"percent": 100})).unwrap(); + frame(&mut a, 2.0); + assert_eq!(a.ui.interface_size.percent(), Some(100)); + } + + /// #822: on a display the system reports at 200 % (a 1080p screen under a bare window manager, + /// where winit guesses the scale from the monitor's DPI), "100%" draws at a true 100 %: one + /// screen pixel per point. Auto keeps the display's scale and says what it draws at. Document + /// zoom is a separate setting. + #[test] + fn interface_size_is_absolute_and_auto_keeps_the_system_scale() { let ctx = egui::Context::default(); let mut a = app(); let doc_zoom = a.session.view.zoom; - a.run("ui.interfaceSize", json!({"percent": 150})).unwrap(); let frame = |a: &mut WordApp, native: f32| { let mut input = egui::RawInput::default(); input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native); ctx.run_ui(input, |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); }; - // The new factor applies from the next pass. - frame(&mut a, 2.0); - frame(&mut a, 2.0); - assert_eq!((ctx.zoom_factor(), ctx.pixels_per_point()), (1.5, 3.0)); - a.run("ui.interfaceSize", json!({"percent": 80})).unwrap(); - frame(&mut a, 1.0); - frame(&mut a, 1.0); - assert_eq!((ctx.zoom_factor(), ctx.pixels_per_point()), (0.8, 0.8)); + let ppp = |a: &mut WordApp, native: f32| { + // The new factor applies from the next pass. + frame(a, native); + frame(a, native); + ctx.pixels_per_point() + }; + assert_eq!(ppp(&mut a, 2.0), 2.0, "Auto keeps the display's scale"); + assert_eq!(a.run("ui.interfaceSize", json!({})).unwrap(), json!({"auto": true, "percent": 200})); + a.run("ui.interfaceSize", json!({"percent": 100})).unwrap(); + assert_eq!(ppp(&mut a, 2.0), 1.0, "100% is one screen pixel per point on a 2× display"); + for native in [1.0, 1.25, 1.5, 2.0, 3.0] { + for pct in [75u16, 100, 150, 200, 300] { + a.run("ui.interfaceSize", json!({"percent": pct})).unwrap(); + let got = ppp(&mut a, native); + assert!((got - f32::from(pct) / 100.0).abs() < 1e-4, "{pct}% on a {native}× display drew at {got}"); + assert_eq!(a.run("ui.interfaceSize", json!({})).unwrap()["percent"], pct, "{pct}% on a {native}× display"); + } + } + a.run("ui.interfaceSize", json!({"value": "auto"})).unwrap(); + assert_eq!(ppp(&mut a, 1.5), 1.5); assert_eq!(a.session.view.zoom, doc_zoom, "document zoom is a separate setting"); } + /// #822: a display scale egui doesn't know or that is nonsense counts as 1×. + #[test] + fn interface_size_survives_an_unusable_display_scale() { + use theme::InterfaceSize; + let size = InterfaceSize::from_percent(125.0).unwrap(); + for native in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -2.0] { + assert_eq!((size.zoom_factor(native), size.drawn_percent(native)), (1.25, 125), "{native}"); + assert_eq!((InterfaceSize::AUTO.zoom_factor(native), InterfaceSize::AUTO.drawn_percent(native)), (1.0, 100), "{native}"); + } + assert_eq!(InterfaceSize::AUTO.drawn_percent(f32::MAX), u16::MAX); + } + /// #115: System follows the OS appearance (light when unknown); the manual choices ignore it. #[test] fn interface_theme_resolves_against_the_os_appearance() { diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 160d3255e..da75c87a4 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -468,15 +468,22 @@ fn theme_picker(app: &mut WordApp, ui: &mut Ui) { }); } -/// Interface size: 80–200 %, on top of the system's display scale (#475). +/// Interface size (#475, #822): Auto, which keeps the system's display scale and shows the size it +/// draws at, or an absolute size, so "100%" means one screen pixel per point. fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { use crate::theme::InterfaceSize; - let current = app.ui.interface_size.percent(); + let size = app.ui.interface_size; + let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); + let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())]); + let selected = size.percent().map_or_else(|| auto.clone(), |pct| format!("{pct}%")); ui.horizontal(|ui| { ui.label(tl!("Interface size:")); - egui::ComboBox::from_id_salt("interface_size").selected_text(format!("{current}%")).width(220.0).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("interface_size").selected_text(selected).width(220.0).show_ui(ui, |ui| { + if ui.selectable_label(size.is_auto(), auto).clicked() { + let _ = app.run("ui.interfaceSize", json!({"value": "auto"})); + } for pct in InterfaceSize::PRESETS { - if ui.selectable_label(current == pct, format!("{pct}%")).clicked() { + if ui.selectable_label(size.percent() == Some(pct), format!("{pct}%")).clicked() { let _ = app.run("ui.interfaceSize", json!({"percent": pct})); } } @@ -484,7 +491,7 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { }); ui.label( RichText::new(tl!( - "Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate." + "Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate." )) .small() .weak(), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ac2ee8b35..441ff2ad9 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -79,55 +79,90 @@ impl From for String { } } -/// Interface size (File › Options › General, `ui.interfaceSize`), in percent (#475): scales the -/// ribbon, menus, panes and dialogs on top of the display scale the system reports, through -/// egui's zoom factor. Document zoom (View › Zoom) is a separate setting and never changes with it. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] -#[serde(from = "serde_json::Value", into = "u16")] -pub struct InterfaceSize(u16); +/// Interface size (File › Options › General, `ui.interfaceSize`): how large the ribbon, menus, panes +/// and dialogs are drawn, through egui's zoom factor. Document zoom (View › Zoom) is a separate +/// setting and never changes with it. +/// +/// `Auto` (the default) keeps the display scale the system reports. A chosen size is absolute, as in +/// PhotoCraft and PdfCraft (#822): "100%" is one screen pixel per point whatever the display reports, +/// so a system that guesses 200 % (a bare window manager on X11, where winit falls back to the +/// monitor's DPI) doesn't make "100%" draw at 200 %. The display's scale is divided back out in +/// [`InterfaceSize::zoom_factor`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(from = "serde_json::Value", into = "serde_json::Value")] +pub struct InterfaceSize(Option); impl InterfaceSize { - pub const MIN: u16 = 80; - pub const MAX: u16 = 200; - pub const DEFAULT: InterfaceSize = InterfaceSize(100); - /// The choices in File › Options, in percent. - pub const PRESETS: [u16; 8] = [80, 90, 100, 110, 125, 150, 175, 200]; + pub const MIN: u16 = 50; + pub const MAX: u16 = 300; + /// Follow the display scale the system reports. + pub const AUTO: InterfaceSize = InterfaceSize(None); + /// The sizes File › Options offers after Auto, in percent (PhotoCraft's and PdfCraft's). + pub const PRESETS: [u16; 12] = [75, 80, 85, 90, 95, 100, 125, 150, 175, 200, 250, 300]; - /// A size from any number: rounded and clamped to 80–200 %; `None` for NaN or infinity. + /// A chosen size from any number: rounded and clamped to 50–300 %; `None` for NaN or infinity. pub fn from_percent(percent: f64) -> Option { - percent.is_finite().then(|| InterfaceSize(percent.round().clamp(f64::from(Self::MIN), f64::from(Self::MAX)) as u16)) + percent.is_finite().then(|| InterfaceSize(Some(percent.round().clamp(f64::from(Self::MIN), f64::from(Self::MAX)) as u16))) } - pub fn percent(self) -> u16 { + /// The chosen size in percent, or `None` for Auto. + pub fn percent(self) -> Option { self.0 } - /// The egui zoom factor (1.0 at 100 %). - pub fn zoom_factor(self) -> f32 { - f32::from(self.0) / 100.0 + pub fn is_auto(self) -> bool { + self.0.is_none() + } + + /// The egui zoom factor on a display whose own scale (native pixels per point) is `native`: + /// 1.0 for Auto, or the chosen size with the display's scale divided back out. + pub fn zoom_factor(self, native: f32) -> f32 { + match self.0 { + None => 1.0, + Some(pct) => f32::from(pct) / 100.0 / usable_scale(native), + } + } + + /// The size the interface is drawn at on that display, in percent (screen pixels per point × 100). + pub fn drawn_percent(self, native: f32) -> u16 { + let drawn = usable_scale(native) * self.zoom_factor(native) * 100.0; + drawn.round().clamp(0.0, f32::from(u16::MAX)) as u16 } } -impl Default for InterfaceSize { - fn default() -> Self { - Self::DEFAULT - } +/// The display's scale, or 1.0 when egui doesn't know it (or reports nonsense). +pub fn usable_scale(native: f32) -> f32 { + if native.is_finite() && native > 0.0 { native } else { 1.0 } } -/// A saved value that isn't a number reads as 100 %, and an out-of-range one is clamped, rather than -/// discarding the whole `ui.json`. +/// `"auto"`, or a percentage; anything else reads as Auto rather than discarding the whole `ui.json`. impl From for InterfaceSize { fn from(v: serde_json::Value) -> Self { v.as_f64().and_then(InterfaceSize::from_percent).unwrap_or_default() } } -impl From for u16 { +impl From for serde_json::Value { fn from(s: InterfaceSize) -> Self { - s.0 + s.0.map_or_else(|| serde_json::Value::from("auto"), serde_json::Value::from) } } +/// A `ui.json` from before #822 saved `interfaceSize` as a percentage on top of the display scale. +/// Its default, 100, meant "the display's scale", which is Auto now (`None`); another value was +/// picked on purpose and is returned, to become an absolute size once the display's scale is +/// known ([`migrate_legacy_interface_size`]). Junk reads as Auto. +pub fn legacy_interface_percent(v: &serde_json::Value) -> Option { + v.as_f64().filter(|p| p.is_finite() && (p - 100.0).abs() >= 0.5) +} + +/// The absolute size a pre-#822 `interfaceSize` of `old` percent drew at on a display whose own +/// scale is `native`: `old × native`, clamped to 50–300 %. A 2× display with 125 % drew at 250 %, +/// and keeps drawing there. +pub fn migrate_legacy_interface_size(old: f64, native: f32) -> InterfaceSize { + InterfaceSize::from_percent(old * f64::from(usable_scale(native))).unwrap_or_default() +} + /// The Animation effects setting (File › Options › Accessibility, `ui.animations`): on, off, or /// `System`, which follows the system's own setting as the host reports it /// ([`Services::animations_enabled`](crate::Services::animations_enabled): Windows' Animation diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 70c84d5c7..4ca1efba5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,8 +50,8 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766)…) | -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5eee00a77..5d93f1068 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-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: clicks on ribbon and status-bar chrome keep typing in the document, #1093; 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-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: a chosen interface size is absolute and Auto keeps the display scale, #822; 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 @@ -35,7 +35,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's; a click sets the proofing language) | ✅ | 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) | ✅ | ✅ | 90% | — | -| Interface size (File › Options › General, 80–200 %, on top of the system display scale; `ui.interfaceSize`) (#475) | ✅ | system display scaling only | 100% | — | +| 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 | | File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — | | Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 | @@ -48,6 +48,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). |---|---|---| | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | +| 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | From aa113f08014ba4f7add1f1bb4239c76fc6b1eb04 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:04:46 +0200 Subject: [PATCH 031/199] Copied equations reach other programs as MathML (#816) (#1147) * #816: copied equations reach other programs as MathML A copy or cut whose selection holds an equation now also puts HTML on the system clipboard, with each equation as Presentation MathML (wrapped in with the linear format as an application/x-unicodemath annotation), so Word, LibreOffice, Google Docs and browsers paste a formatted equation instead of `y=2x_s`. - formats: new mathml module, a writer from the equation tree (runs, fractions, scripts and pre-scripts, radicals, n-ary operators, delimiters, functions, limits, accents, bars, border boxes, group characters, equation arrays, matrices, phantoms) and a bounded reader (our annotation, else the presentation elements, else a TeX annotation). HTML export writes equations as MathML; HTML import (open and paste) reads back. - engine: edit.copy/edit.cut (and column copy) return `html` and keep Session::clipboard_html when the fragment holds an equation. - ui-egui: Services::set_clipboard_html; copy sites put HTML with the text through it, falling back to egui's text copy. - wordcraft: arboard set().html() with the text alternative; on Wayland without data-control the text-only path is kept. OMML on the clipboard (Word's "Office Open XML") needs custom clipboard formats arboard can't set; left as a follow-up. Co-Authored-By: Claude Opus 5.5 * #816: honest clipboard docs; MathML reader fixes (token cap, function names, column vectors) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 +- apps/wordcraft/src/clipboard.rs | 51 ++ apps/wordcraft/src/main.rs | 1 + crates/doc/src/edit.rs | 19 +- crates/engine/src/cmd/column.rs | 2 +- crates/engine/src/cmd/edit.rs | 12 +- crates/engine/src/session.rs | 5 + crates/engine/src/tests.rs | 49 ++ crates/formats/src/html.rs | 102 ++- crates/formats/src/lib.rs | 1 + crates/formats/src/mathml.rs | 895 +++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/keys.rs | 7 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/src/system_clipboard.rs | 48 ++ docs/file-format-parity.md | 5 +- docs/gaps.md | 10 +- docs/ui-parity.md | 3 +- 19 files changed, 1200 insertions(+), 25 deletions(-) create mode 100644 crates/formats/src/mathml.rs diff --git a/ROADMAP.md b/ROADMAP.md index 07e73837e..0500b70bf 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: absolute interface sizes, #822; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: equations copy as MathML, #816; previously trivial: progress log: 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. @@ -146,6 +146,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) | | 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged | | 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | +| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/apps/wordcraft/src/clipboard.rs b/apps/wordcraft/src/clipboard.rs index ecd284679..1776d8fd0 100644 --- a/apps/wordcraft/src/clipboard.rs +++ b/apps/wordcraft/src/clipboard.rs @@ -8,6 +8,11 @@ //! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11. //! //! Everything read here comes from other programs: sizes are capped as it is read. +//! +//! Written here: a copy holding an equation puts HTML (the equation as MathML) beside its text +//! (#816), for programs that read MathML (pasting into Word and LibreOffice not yet verified). arboard sets HTML with a +//! text alternative on every desktop platform; formats of other names (Word's "Office Open XML" +//! with OMML, #1297) it can't, so they aren't offered. use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES; use wordcraft_ui_egui::paste_picture::ClipboardPicture; @@ -16,6 +21,35 @@ fn open() -> Option { arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok() } +thread_local! { + /// The clipboard copies are set through, kept open: on X11 the contents are served from it. + static WRITER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Put `html` on the clipboard with `text` as its plain-text alternative. `false` when it couldn't +/// (the caller copies the text alone), including a Wayland session without the data-control +/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it. +pub fn set_html(html: &str, text: &str) -> bool { + if !platform::can_set() { + return false; + } + WRITER.with(|w| { + let Ok(mut slot) = w.try_borrow_mut() else { return false }; + if slot.is_none() { + *slot = open(); + } + match slot.as_mut().map(|c| c.set().html(html, Some(text))) { + Some(Ok(())) => true, + Some(Err(e)) => { + log::warn!("clipboard: {e}"); + *slot = None; + false + } + None => false, + } + }) +} + /// The clipboard's text. pub fn text() -> Option { open()?.get_text().ok() @@ -103,6 +137,15 @@ mod platform { get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default() } + /// Whether arboard sets the clipboard natively: X11, or Wayland with data-control. + pub fn can_set() -> bool { + !wayland() + || !matches!( + get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified), + Err(wl_clipboard_rs::paste::Error::MissingProtocol { .. } | wl_clipboard_rs::paste::Error::WaylandConnection(_)) + ) + } + pub fn rtf() -> Option> { if !wayland() { return None; @@ -146,6 +189,10 @@ mod platform { pub fn encoded_picture() -> Option> { None } + + pub fn can_set() -> bool { + true + } } #[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))] @@ -161,4 +208,8 @@ mod platform { pub fn encoded_picture() -> Option> { None } + + pub fn can_set() -> bool { + true + } } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 988b4f67b..62b51d4a2 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -212,6 +212,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { animations_enabled: Some(animations::hook(ctx)), clipboard_text: Some(Box::new(clipboard::text)), clipboard_html: Some(Box::new(clipboard::html)), + set_clipboard_html: Some(Box::new(clipboard::set_html)), clipboard_rtf: Some(Box::new(clipboard::rtf)), clipboard_formats: Some(Box::new(clipboard::formats)), ..Default::default() diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 86c334dd7..6c36f9295 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -23,16 +23,27 @@ pub struct Fragment { impl Fragment { pub fn plain_text(&self) -> String { + self.to_document().plain_text(crate::StoryRef::Body) + } + /// The fragment as the body of a new document (default styles). + pub fn to_document(&self) -> Document { let mut d = Document::new(); d.body = self.blocks.iter().cloned().map(Arc::new).collect(); - d.plain_text(crate::StoryRef::Body) + d + } + /// Whether the fragment holds an equation (in tables too), which a copy also puts on the + /// system clipboard as MathML (#816). + pub fn has_equation(&self) -> bool { + let mut found = false; + for b in &self.blocks { + each_para(b, 0, &mut |p| found |= p.objects.iter().any(|o| matches!(o, InlineObject::Equation { .. }))); + } + found } /// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters /// (Wingdings…) as their Unicode equivalents. pub fn clipboard_text(&self) -> String { - let mut d = Document::new(); - d.body = self.blocks.iter().cloned().map(Arc::new).collect(); - d.shown_text(crate::StoryRef::Body) + self.to_document().shown_text(crate::StoryRef::Body) } pub fn from_text(s: &str) -> Fragment { let lines: Vec<&str> = s.split('\n').collect(); diff --git a/crates/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs index 1db3cd1b4..faff98ec7 100644 --- a/crates/engine/src/cmd/column.rs +++ b/crates/engine/src/cmd/column.rs @@ -211,7 +211,7 @@ fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult { } s.clipboard_text = rows.join("\n"); s.clipboard = Some(Fragment { blocks, parts }); - Ok(json!({"text": s.clipboard_text})) + Ok(super::edit::copy_result(s)) } /// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d239a5ede..a61b0a9fe 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -162,7 +162,17 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult { s.clipboard_text = f.clipboard_text(); s.clip_history.push(&f, &s.clipboard_text); s.clipboard = Some(f); - Ok(json!({"text": s.clipboard_text})) + Ok(copy_result(s)) +} + +/// What Copy and Cut return: the clipboard's text, and HTML with the equations as MathML when +/// the copy holds any (#816), for the host to put on the system clipboard too. +pub(crate) fn copy_result(s: &mut Session) -> Value { + s.clipboard_html = match &s.clipboard { + Some(f) if f.has_equation() => wordcraft_formats::html::export_fragment(&f.to_document()), + _ => String::new(), + }; + if s.clipboard_html.is_empty() { json!({"text": s.clipboard_text}) } else { json!({"text": s.clipboard_text, "html": s.clipboard_html}) } } fn cut(s: &mut Session, v: &Value) -> CmdResult { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1f4734c5c..722f9c694 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -338,6 +338,9 @@ pub struct Session { pub pending_move: Option, /// Plain text mirror of the clipboard (for the system clipboard). pub clipboard_text: String, + /// HTML for the system clipboard beside [`Self::clipboard_text`] when the copy holds an + /// equation (as MathML, #816), for programs that read MathML; empty otherwise. + pub clipboard_html: String, /// Items collected by Copy and Cut this session, for the Clipboard pane. pub clip_history: crate::cmd::edit::ClipHistory, pub find: FindState, @@ -574,6 +577,7 @@ impl Session { clipboard: None, pending_move: None, clipboard_text: String::new(), + clipboard_html: String::new(), clip_history: Default::default(), find: FindState::default(), goal_x: None, @@ -1005,6 +1009,7 @@ impl Session { // Like the clipboard: what the next paste can make a move of. pending_move: _, clipboard_text: _, + clipboard_html: _, clip_history: _, find: _, goal_x: _, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed82ad3bf..143cc4721 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -813,6 +813,55 @@ fn clipboard_round_trip() { assert_eq!(text(&s), "plain\ntext"); } +/// Equations in the paragraphs of a session's body, as linear text. +fn equations(s: &Session) -> Vec { + let mut out = Vec::new(); + for b in &s.doc.body { + if let wordcraft_doc::Block::Para(p) = &**b { + for o in &p.objects { + if let wordcraft_doc::para::InlineObject::Equation { linear, .. } = o { + out.push(linear.clone()); + } + } + } + } + out +} + +#[test] +fn copying_an_equation_puts_mathml_on_the_clipboard() { + // #816: other programs get the equation as MathML in HTML, beside the linear text. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Speed "})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.equation", json!({"linear": "y=2x_s", "edit": false})); + run(&mut s, "select.all", json!({})); + let r = run(&mut s, "edit.copy", json!({})); + let html = r["html"].as_str().unwrap_or_default().to_string(); + assert!(html.contains(""), "{html}"); + assert!(html.contains("xs"), "{html}"); + assert!(html.contains("Speed"), "{html}"); + assert_eq!(s.clipboard_html, html); + assert!(r["text"].as_str().unwrap_or_default().contains("y=2x_s")); + let clip = r["text"].as_str().unwrap_or_default().to_string(); + + // Pasting it into another document reads the equation back from the HTML. + let mut t = Session::new(wordcraft_doc::Document::new()); + run(&mut t, "edit.paste", json!({"text": clip, "html": html})); + assert_eq!(equations(&t), vec!["y=2x_s".to_string()]); + assert!(text(&t).starts_with("Speed"), "{}", text(&t)); + + // And back into this one, from our own copy. + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.paste", json!({"text": clip, "html": html})); + assert_eq!(equations(&s), vec!["y=2x_s".to_string(), "y=2x_s".to_string()]); + + // A copy without an equation carries no HTML. + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})); + let r = run(&mut s, "edit.copy", json!({})); + assert!(r.get("html").is_none() && s.clipboard_html.is_empty(), "{r}"); +} + #[test] fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() { let mut s = s(); diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 073ca7532..5df84a0aa 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -1125,6 +1125,15 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow { Tok::Text(t) => b.text(&t), Tok::Start { name, attrs, self_close } => { b.close_head_for_start(&name); + if crate::mathml::local(&name) == "math" && !self_close { + let el = read_math(&mut lx, name, attrs); + if !b.in_head() + && let Some((linear, display)) = crate::mathml::read(&el) + { + b.start_para().inlines.push(Inline::Equation { linear, display }); + } + continue; + } if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close { lx.skip_past(&format!(""); @@ -1138,6 +1147,86 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow { b.finish() } +/// A `` element (#816): its tokens up to the matching end tag, as a tree. Nesting and size +/// are bounded; an unclosed element ends with the input. +fn read_math(lx: &mut Lexer, name: String, attrs: Vec<(String, String)>) -> crate::mathml::El { + use crate::mathml::{El, Item, MAX_NODES, local}; + let mut stack: Vec = vec![El { name, attrs, children: Vec::new() }]; + let mut count = 0usize; + let close = |stack: &mut Vec| { + if stack.len() > 1 + && let Some(done) = stack.pop() + && let Some(parent) = stack.last_mut() + { + parent.children.push(Item::El(done)); + } + }; + while let Some(t) = lx.next() { + count += 1; + if count > MAX_NODES * 4 { + // Over the cap: drop the rest of this element so its tokens aren't read as HTML text. + skip_math(lx, t); + break; + } + match t { + Tok::Text(t) => { + if let Some(top) = stack.last_mut() + && !t.is_empty() + { + top.children.push(Item::Text(t)); + } + } + Tok::Start { name, attrs, self_close } => { + let el = El { name, attrs, children: Vec::new() }; + if self_close || stack.len() > wordcraft_doc::math::MAX_DEPTH { + if let Some(top) = stack.last_mut() { + top.children.push(Item::El(el)); + } + } else { + stack.push(el); + } + } + Tok::End(name) => { + let name = local(&name).to_string(); + // Close up to the matching open element; a stray end tag is ignored. + if let Some(k) = stack.iter().rposition(|e| local(&e.name) == name) { + if k == 0 { + break; + } + while stack.len() > k { + close(&mut stack); + } + } + } + } + } + while stack.len() > 1 { + close(&mut stack); + } + stack.pop().unwrap_or_default() +} + +/// Skip tokens (starting with `first`) up to and including the `` that closes the element +/// being read; nested `` elements are counted. An unclosed element ends with the input. +fn skip_math(lx: &mut Lexer, first: Tok) { + use crate::mathml::local; + let mut depth = 0usize; + let mut t = Some(first); + while let Some(tok) = t { + match tok { + Tok::Start { name, self_close: false, .. } if local(&name) == "math" => depth = depth.saturating_add(1), + Tok::End(name) if local(&name) == "math" => { + if depth == 0 { + return; + } + depth = depth.saturating_sub(1); + } + _ => {} + } + t = lx.next(); + } +} + /// Parse HTML into a document. pub fn import(s: &str) -> Document { model::to_doc(&parse(s)) @@ -1198,7 +1287,6 @@ fn text_html(t: &str) -> String { /// Inline spans → HTML. pub fn inlines_html(inlines: &[Inline]) -> String { let mut o = String::new(); - let inlines = crate::model::equations_as_text(inlines); for i in inlines.iter() { match i { Inline::Text(t, f) => { @@ -1269,7 +1357,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String { Inline::Anchor(a) => o.push_str(&format!("", esc(a))), // No picture to show: the browser shows the alt text in its place. Inline::Figure(alt) => o.push_str(&format!("\"{}\"", esc(alt))), - Inline::Equation { .. } => {} + // MathML, which browsers, Word and LibreOffice show as an equation (#816). + Inline::Equation { linear, display } => o.push_str(&crate::mathml::linear_to_mathml(linear, *display)), } } o @@ -1448,6 +1537,15 @@ pub fn export(doc: &Document) -> String { export_flow(&flow, doc.styles.default_chr.lang.as_deref().unwrap_or("en")) } +/// `doc`'s content as an HTML fragment (no head or style sheet): what a copy puts on the system +/// clipboard beside its text, equations as MathML (#816). +pub fn export_fragment(doc: &Document) -> String { + let flow = model::from_doc(doc); + let mut out = String::from(""); + blocks_html(&flow.blocks, &mut out, 0); + out +} + pub fn export_flow(flow: &Flow, lang: &str) -> String { let m = &flow.meta; let mut out = String::new(); diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 0c72e9ee8..be4f3cabd 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -13,6 +13,7 @@ pub mod comments; pub mod html; pub mod latex; pub mod markdown; +pub mod mathml; pub mod model; pub mod odt; pub mod rtf; diff --git a/crates/formats/src/mathml.rs b/crates/formats/src/mathml.rs new file mode 100644 index 000000000..bbb135844 --- /dev/null +++ b/crates/formats/src/mathml.rs @@ -0,0 +1,895 @@ +//! Equations as MathML (W3C MathML Core / Presentation MathML): written into HTML export and the +//! HTML a copy puts on the system clipboard, so programs that read MathML can get a formatted +//! equation instead of its linear text (#816; pasting into Word and LibreOffice not yet verified; +//! OMML for Word is #1297); and read back from pasted or opened HTML. +//! +//! The writer walks the equation tree ([`MNode`]) and wraps it in `` with the linear +//! format as an `application/x-unicodemath` annotation, which renderers ignore and WordCraft reads +//! back exactly. The reader takes that annotation when present, else a TeX annotation, else +//! builds the tree from the presentation elements (fractions, scripts, radicals, under/over +//! limits, tables, fences); both bound their nesting and size. + +use wordcraft_doc::math::{ + Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, is_nary_char, math_alnum, math_class, + to_linear, +}; +use wordcraft_doc::math_linear::FUNCTION_NAMES; + +use crate::html::esc; + +/// The MathML namespace. +pub const NS: &str = "http://www.w3.org/1998/Math/MathML"; +/// The annotation encoding carrying the linear format (UnicodeMath). +pub const UNICODEMATH: &str = "application/x-unicodemath"; + +// --------------------------------------------------------------------------------------------- +// Writer + +/// An equation as a `` element. `linear` (the linear format) goes in an annotation for an +/// exact round trip; empty = taken from the tree. +pub fn to_mathml(nodes: &[MNode], display: bool, linear: &str) -> String { + let mut o = String::new(); + o.push_str(&format!(""); + arg(nodes, &mut o, 0, false); + o.push_str(""); + let linear = if linear.is_empty() { to_linear(nodes) } else { linear.to_string() }; + if !linear.is_empty() { + o.push_str(&format!("{}", esc(&linear))); + } + o.push_str(""); + o +} + +/// An equation known by its linear format, as a `` element. +pub fn linear_to_mathml(linear: &str, display: bool) -> String { + to_mathml(&wordcraft_doc::math::parse_linear(linear), display, linear) +} + +/// An argument as a row of elements. `eq_arr`: a row of an equation array, whose `&` marks are +/// alignment points, not text. +fn arg(a: &[MNode], o: &mut String, depth: usize, eq_arr: bool) { + if depth > MAX_DEPTH { + return; + } + for n in a { + node(n, o, depth + 1, eq_arr); + } +} + +/// An argument as one element (`` around several). +fn group(a: &[MNode], o: &mut String, depth: usize) { + o.push_str(""); + arg(a, o, depth, false); + o.push_str(""); +} + +fn mo(c: char, attrs: &str, o: &mut String) { + o.push_str(&format!("{}", esc(&c.to_string()))); +} + +fn run(r: &MRun, o: &mut String, eq_arr: bool) { + let color = r.color.map(|c| format!(" mathcolor=\"#{}\"", c.hex())).unwrap_or_default(); + if r.nor { + if !r.text.is_empty() { + o.push_str(&format!("{}", esc(&r.text))); + } + return; + } + let bold = matches!(r.sty, Some(MSty::Bold | MSty::BoldItalic)); + let italic = match r.sty { + None => r.scr == MScr::Roman, + Some(s) => matches!(s, MSty::Italic | MSty::BoldItalic), + }; + let cs: Vec = r.text.chars().collect(); + let mut i = 0; + while let Some(&c) = cs.get(i) { + if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit())) { + let mut num = String::new(); + while let Some(&d) = + cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(|n| n.is_ascii_digit())) + { + num.push(d); + i += 1; + } + let num: String = if bold { num.chars().map(|d| math_alnum(d, true, false, MScr::Roman)).collect() } else { num }; + o.push_str(&format!("{}", esc(&num))); + continue; + } + i += 1; + if c.is_whitespace() || (eq_arr && c == '&') { + continue; + } + if c.is_alphabetic() { + if bold || r.scr != MScr::Roman { + let m = math_alnum(c, bold, italic, r.scr); + let plain = if m == c && !italic { " mathvariant=\"normal\"" } else { "" }; + o.push_str(&format!("{}", esc(&m.to_string()))); + } else if italic { + o.push_str(&format!("{}", esc(&c.to_string()))); + } else { + o.push_str(&format!("{}", esc(&c.to_string()))); + } + } else if math_class(c) == MClass::Ord && !c.is_ascii_punctuation() && c != '\u{2061}' { + // ∞, ∂, ∅, ℏ…: identifiers, not operators. + o.push_str(&format!("{}", esc(&c.to_string()))); + } else { + mo(c, &color, o); + } + } +} + +/// A function name (`sin`, `lim`): one upright identifier when it's plain text. +fn func_name(name: &[MNode], o: &mut String, depth: usize) { + match name { + [MNode::Run(r)] if !r.nor && r.text.chars().all(char::is_alphabetic) && !r.text.is_empty() => { + o.push_str(&format!("{}", esc(&r.text))); + } + _ => group(name, o, depth), + } +} + +fn node(n: &MNode, o: &mut String, depth: usize, eq_arr: bool) { + match n { + MNode::Run(r) => run(r, o, eq_arr), + MNode::Frac { kind, num, den } => match kind { + FracKind::Bar | FracKind::NoBar => { + o.push_str(if *kind == FracKind::NoBar { "" } else { "" }); + group(num, o, depth); + group(den, o, depth); + o.push_str(""); + } + FracKind::Skewed | FracKind::Linear => { + o.push_str(""); + group(num, o, depth); + mo(if *kind == FracKind::Skewed { '⁄' } else { '/' }, "", o); + group(den, o, depth); + o.push_str(""); + } + }, + MNode::Script { kind, base, sub, sup } => match kind { + ScriptKind::Sup => { + o.push_str(""); + group(base, o, depth); + group(sup, o, depth); + o.push_str(""); + } + ScriptKind::Sub => { + o.push_str(""); + group(base, o, depth); + group(sub, o, depth); + o.push_str(""); + } + ScriptKind::SubSup => { + o.push_str(""); + group(base, o, depth); + group(sub, o, depth); + group(sup, o, depth); + o.push_str(""); + } + ScriptKind::Pre => { + o.push_str(""); + group(base, o, depth); + o.push_str(""); + group(sub, o, depth); + group(sup, o, depth); + o.push_str(""); + } + }, + MNode::Rad { deg, deg_hide, e } => { + if *deg_hide || deg.is_empty() { + o.push_str(""); + arg(e, o, depth, false); + o.push_str(""); + } else { + o.push_str(""); + group(e, o, depth); + group(deg, o, depth); + o.push_str(""); + } + } + MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => { + let under_over = match lim_loc { + Some(l) => *l == LimLoc::UndOvr, + None => !is_integral(*chr), + }; + let has_sub = !*sub_hide && !sub.is_empty(); + let has_sup = !*sup_hide && !sup.is_empty(); + let tag = match (under_over, has_sub, has_sup) { + (_, false, false) => "", + (true, true, true) => "munderover", + (true, true, false) => "munder", + (true, false, true) => "mover", + (false, true, true) => "msubsup", + (false, true, false) => "msub", + (false, false, true) => "msup", + }; + o.push_str(""); + if !tag.is_empty() { + o.push_str(&format!("<{tag}>")); + } + mo(*chr, " largeop=\"true\" movablelimits=\"false\"", o); + if has_sub { + group(sub, o, depth); + } + if has_sup { + group(sup, o, depth); + } + if !tag.is_empty() { + o.push_str(&format!("")); + } + group(e, o, depth); + o.push_str(""); + } + MNode::Delim { beg, end, sep, grow, elems, .. } => { + let stretchy = if *grow { "" } else { " stretchy=\"false\"" }; + o.push_str(""); + if let Some(b) = beg { + mo(*b, &format!(" fence=\"true\" form=\"prefix\"{stretchy}"), o); + } + for (i, e) in elems.iter().enumerate() { + if i > 0 { + mo(sep.unwrap_or('|'), &format!(" separator=\"true\"{stretchy}"), o); + } + group(e, o, depth); + } + if let Some(c) = end { + mo(*c, &format!(" fence=\"true\" form=\"postfix\"{stretchy}"), o); + } + o.push_str(""); + } + MNode::Func { name, e } => { + o.push_str(""); + func_name(name, o, depth); + mo('\u{2061}', "", o); + group(e, o, depth); + o.push_str(""); + } + MNode::Lim { upper, e, lim } => { + let tag = if *upper { "mover" } else { "munder" }; + o.push_str(&format!("<{tag}>")); + func_name(e, o, depth); + group(lim, o, depth); + o.push_str(&format!("")); + } + MNode::Acc { chr, e } => { + o.push_str(""); + group(e, o, depth); + mo(spacing_accent(*chr), "", o); + o.push_str(""); + } + MNode::Bar { top, e } => { + if *top { + o.push_str(""); + group(e, o, depth); + mo('\u{203E}', "", o); + o.push_str(""); + } else { + o.push_str(""); + group(e, o, depth); + mo('_', "", o); + o.push_str(""); + } + } + MNode::BorderBox { hide, strike, e } => { + let mut notation: Vec<&str> = Vec::new(); + for (h, name) in hide.iter().zip(["top", "bottom", "left", "right"]) { + if !h { + notation.push(name); + } + } + if notation.len() == 4 { + notation = vec!["box"]; + } + for (s, name) in strike.iter().zip(["horizontalstrike", "verticalstrike", "updiagonalstrike", "downdiagonalstrike"]) { + if *s { + notation.push(name); + } + } + o.push_str(&format!("", notation.join(" "))); + arg(e, o, depth, false); + o.push_str(""); + } + MNode::Boxed { e } => group(e, o, depth), + MNode::GroupChr { chr, top, e } => { + let tag = if *top { "mover" } else { "munder" }; + o.push_str(&format!("<{tag}>")); + group(e, o, depth); + mo(*chr, " stretchy=\"true\"", o); + o.push_str(&format!("")); + } + MNode::EqArr { rows } => { + // `displaystyle` marks an equation array; a bare one-column table reads as a matrix. + o.push_str(""); + for r in rows { + o.push_str(""); + arg(r, o, depth, true); + o.push_str(""); + } + o.push_str(""); + } + MNode::Matrix { rows, col_jc } => { + o.push_str(""); + for r in rows { + o.push_str(""); + for (k, cell) in r.iter().enumerate() { + match col_jc.get(k) { + Some(ColJc::Left) => o.push_str(""), + Some(ColJc::Right) => o.push_str(""), + _ => o.push_str(""), + } + arg(cell, o, depth, false); + o.push_str(""); + } + o.push_str(""); + } + o.push_str(""); + } + MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { + let mut pad = String::new(); + for (z, a) in [(zero_wid, "width"), (zero_asc, "height"), (zero_desc, "depth")] { + if *z { + pad.push_str(&format!(" {a}=\"0\"")); + } + } + if !pad.is_empty() { + o.push_str(&format!("")); + } + o.push_str(if *show { "" } else { "" }); + arg(e, o, depth, false); + o.push_str(if *show { "" } else { "" }); + if !pad.is_empty() { + o.push_str(""); + } + } + } +} + +/// Combining accents (how equations keep them) and the spacing characters MathML puts over the +/// base, both ways. +const ACCENTS: &[(char, char)] = &[ + ('\u{0302}', '^'), + ('\u{0303}', '~'), + ('\u{0304}', '¯'), + ('\u{0305}', '\u{203E}'), + ('\u{0306}', '˘'), + ('\u{0307}', '˙'), + ('\u{0308}', '¨'), + ('\u{030C}', 'ˇ'), + ('\u{0301}', '´'), + ('\u{0300}', '`'), + ('\u{030A}', '˚'), + ('\u{20D7}', '→'), + ('\u{20D6}', '←'), + ('\u{20E1}', '↔'), + ('\u{20D1}', '⇀'), + ('\u{20D0}', '↼'), +]; + +fn spacing_accent(c: char) -> char { + ACCENTS.iter().find(|(comb, _)| *comb == c).map(|(_, s)| *s).unwrap_or(c) +} + +fn combining_accent(c: char) -> Option { + let c = match c { + 'ˆ' => '^', + '˜' => '~', + '‾' => '\u{203E}', + '\u{20D7}' | '\u{0302}' | '\u{0303}' | '\u{0307}' | '\u{0308}' => return Some(c), + c => c, + }; + ACCENTS.iter().find(|(_, s)| *s == c).map(|(comb, _)| *comb) +} + +// --------------------------------------------------------------------------------------------- +// Reader + +/// Most elements read from one `` element. +pub const MAX_NODES: usize = 20_000; + +/// A parsed MathML element (namespace prefixes dropped, names lowercase). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct El { + pub name: String, + pub attrs: Vec<(String, String)>, + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Item { + El(El), + Text(String), +} + +impl El { + fn attr(&self, name: &str) -> Option<&str> { + self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) + } + fn els(&self) -> impl Iterator { + self.children.iter().filter_map(|c| match c { + Item::El(e) => Some(e), + Item::Text(_) => None, + }) + } + /// The text inside, all descendants (depth-limited). + pub fn text(&self) -> String { + let mut s = String::new(); + text_into(self, &mut s, 0); + s + } +} + +fn text_into(e: &El, s: &mut String, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for c in &e.children { + match c { + Item::Text(t) => s.push_str(t), + Item::El(c) => text_into(c, s, depth + 1), + } + } +} + +/// The element name without a namespace prefix (`m:math`, `mml:mfrac`). +pub fn local(name: &str) -> &str { + name.rsplit(':').next().unwrap_or(name) +} + +/// 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 linear = linear_of(math); + let linear: String = linear.chars().filter(|c| !c.is_control()).collect(); + let linear = linear.trim().to_string(); + (!linear.is_empty()).then_some((linear, display)) +} + +fn linear_of(math: &El) -> String { + // Our own copy carries the linear format exactly. + if let Some(sem) = math.els().find(|e| local(&e.name) == "semantics") { + let ann = |enc: &dyn Fn(&str) -> bool| { + sem.els() + .filter(|e| local(&e.name) == "annotation") + .find(|e| e.attr("encoding").is_some_and(|v| enc(&v.to_ascii_lowercase()))) + .map(El::text) + .filter(|t| !t.trim().is_empty()) + }; + if let Some(t) = ann(&|v| v == UNICODEMATH) { + return t; + } + let tree = to_tree(math); + if !tree.is_empty() { + return to_linear(&tree); + } + if let Some(t) = ann(&|v| v.contains("tex")) { + return to_linear(&wordcraft_doc::math_latex::parse_latex(&t)); + } + return String::new(); + } + to_linear(&to_tree(math)) +} + +/// A MathML element's children as an equation tree. +pub fn to_tree(math: &El) -> Arg { + let mut budget = MAX_NODES; + wordcraft_doc::math::merge_runs(children(math, 0, &mut budget)) +} + +fn children(e: &El, depth: usize, budget: &mut usize) -> Arg { + let els: Vec<&El> = e.els().collect(); + seq(&els, depth, budget) +} + +/// A sequence of elements; an n-ary operator without an operand takes the next element. +fn seq(els: &[&El], depth: usize, budget: &mut usize) -> Arg { + let mut out: Arg = Vec::new(); + let mut i = 0; + while let Some(el) = els.get(i) { + i += 1; + // A function name (`sin`) applies to the next element even without U+2061, + // so it doesn't merge with it into `sinx`. Before an operator (`(`, U+2061) the runs + // merge and the linear format finds the function itself. + if local(&el.name) == "mi" + && let Some(next) = els.get(i).filter(|n| local(&n.name) != "mo") + { + let t = el.text(); + let t = t.trim(); + if FUNCTION_NAMES.contains(&t) && *budget > 0 { + *budget -= 1; + i += 1; + out.push(MNode::Func { name: vec![MNode::Run(MRun::plain(t))], e: elem(next, depth + 1, budget) }); + continue; + } + } + let mut a = elem(el, depth, budget); + if let [MNode::Nary { e, .. }] = a.as_mut_slice() + && e.is_empty() + && let Some(next) = els.get(i) + { + i += 1; + *e = elem(next, depth, budget); + } + out.extend(a); + } + out +} + +/// The `k`th child element as an argument. +fn nth(e: &El, k: usize, depth: usize, budget: &mut usize) -> Arg { + e.els().nth(k).map(|c| elem(c, depth, budget)).unwrap_or_default() +} + +/// A lone operator character (`∑`). +fn lone_char(a: &[MNode]) -> Option { + match a { + [MNode::Run(r)] => { + let mut cs = r.text.trim().chars(); + let c = cs.next()?; + cs.next().is_none().then_some(c) + } + _ => None, + } +} + +/// A plain word (`lim`, `max`). +fn word(a: &[MNode]) -> bool { + matches!(a, [MNode::Run(r)] if r.text.chars().count() > 1 && r.text.chars().all(char::is_alphabetic)) +} + +fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg { + if depth > MAX_DEPTH || *budget == 0 { + return Vec::new(); + } + *budget -= 1; + let d = depth + 1; + match local(&e.name) { + "mi" | "mn" | "mo" | "ms" => { + let t = e.text(); + let t = t.trim(); + if t.is_empty() { + return Vec::new(); + } + let mut r = MRun::new(t); + if local(&e.name) == "mi" && e.attr("mathvariant") == Some("normal") && t.chars().count() == 1 { + r.sty = Some(MSty::Plain); + } + vec![MNode::Run(r)] + } + "mtext" => { + let t = e.text(); + if t.trim().is_empty() { + return Vec::new(); + } + vec![MNode::Run(MRun { text: t, nor: true, ..Default::default() })] + } + "mfrac" => { + let thin = e.attr("linethickness").is_some_and(|t| t.trim().trim_end_matches(|c: char| c.is_alphabetic()).trim() == "0"); + let kind = if e.attr("bevelled") == Some("true") { + FracKind::Skewed + } else if thin { + FracKind::NoBar + } else { + FracKind::Bar + }; + vec![MNode::Frac { kind, num: nth(e, 0, d, budget), den: nth(e, 1, d, budget) }] + } + "msqrt" => vec![MNode::Rad { deg: Vec::new(), deg_hide: true, e: children(e, d, budget) }], + "mroot" => vec![MNode::Rad { deg: nth(e, 1, d, budget), deg_hide: false, e: nth(e, 0, d, budget) }], + "msup" | "msub" | "msubsup" => { + let name = local(&e.name); + let base = nth(e, 0, d, budget); + let (sub, sup) = match name { + "msup" => (Vec::new(), nth(e, 1, d, budget)), + "msub" => (nth(e, 1, d, budget), Vec::new()), + _ => (nth(e, 1, d, budget), nth(e, 2, d, budget)), + }; + if let Some(c) = lone_char(&base).filter(|c| is_nary_char(*c)) { + return vec![nary(c, LimLoc::SubSup, sub, sup)]; + } + let kind = match name { + "msup" => ScriptKind::Sup, + "msub" => ScriptKind::Sub, + _ => ScriptKind::SubSup, + }; + vec![MNode::Script { kind, base, sub, sup }] + } + "munder" | "mover" | "munderover" => { + let name = local(&e.name); + let base = nth(e, 0, d, budget); + let (under, over) = match name { + "munder" => (nth(e, 1, d, budget), Vec::new()), + "mover" => (Vec::new(), nth(e, 1, d, budget)), + _ => (nth(e, 1, d, budget), nth(e, 2, d, budget)), + }; + if let Some(c) = lone_char(&base).filter(|c| is_nary_char(*c)) { + return vec![nary(c, LimLoc::UndOvr, under, over)]; + } + if name == "munderover" { + let inner = MNode::Lim { upper: false, e: base, lim: under }; + return vec![MNode::Lim { upper: true, e: vec![inner], lim: over }]; + } + let upper = name == "mover"; + let mark = if upper { lone_char(&over) } else { lone_char(&under) }; + match mark { + Some('\u{203E}' | '¯' | '‾') if upper => vec![MNode::Bar { top: true, e: base }], + Some('_' | '\u{203E}' | '¯') if !upper => vec![MNode::Bar { top: false, e: base }], + Some(c @ ('⏞' | '⏟' | '⏜' | '⏝' | '⎴' | '⎵' | '←' | '→' | '↔' | '⇐' | '⇒' | '⇔')) if !word(&base) => { + vec![MNode::GroupChr { chr: c, top: upper, e: base }] + } + Some(c) if upper && combining_accent(c).is_some() => vec![MNode::Acc { chr: combining_accent(c).unwrap_or(c), e: base }], + _ => vec![MNode::Lim { upper, e: base, lim: if upper { over } else { under } }], + } + } + "mmultiscripts" => { + let els: Vec<&El> = e.els().collect(); + let base = els.first().map(|b| elem(b, d, budget)).unwrap_or_default(); + let pre_at = els.iter().position(|c| local(&c.name) == "mprescripts"); + let script = + |k: usize, budget: &mut usize| els.get(k).filter(|c| local(&c.name) != "none").map(|c| elem(c, d, budget)).unwrap_or_default(); + match pre_at { + Some(p) => { + let sub = script(p + 1, budget); + let sup = script(p + 2, budget); + vec![MNode::Script { kind: ScriptKind::Pre, base, sub, sup }] + } + None => { + let sub = script(1, budget); + let sup = script(2, budget); + let kind = match (sub.is_empty(), sup.is_empty()) { + (true, true) => return base, + (false, true) => ScriptKind::Sub, + (true, false) => ScriptKind::Sup, + (false, false) => ScriptKind::SubSup, + }; + vec![MNode::Script { kind, base, sub, sup }] + } + } + } + "mfenced" => { + let first = |a: &str, dflt: char| e.attr(a).map(|v| v.trim().chars().next()).unwrap_or(Some(dflt)); + let beg = first("open", '('); + let end = first("close", ')'); + let sep = first("separators", ','); + let elems: Vec = e.els().map(|c| elem(c, d, budget)).collect(); + 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(); + if rows.is_empty() { + return Vec::new(); + } + // One column is an equation array only when marked as one (`displaystyle`, alignment + // marks, labelled rows, as TeX's align and our own writer give); else a column vector. + let marked = + e.attr("displaystyle").is_some_and(|v| v.trim() == "true") || e.els().any(|r| local(&r.name) == "mlabeledtr") || has_align_mark(e, 0); + 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() }] + } + "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) }]; + } + children(e, d, budget) + } + // The first child of `semantics` is the presentation; annotations aren't content. + "semantics" => e.els().next().map(|c| elem(c, d, budget)).unwrap_or_default(), + "annotation" | "annotation-xml" | "mspace" | "none" | "mprescripts" | "maligngroup" | "malignmark" => Vec::new(), + _ => children(e, d, budget), + } +} + +/// Whether `e` holds a `maligngroup` or `malignmark` near the top (in a cell or a row inside it). +fn has_align_mark(e: &El, depth: usize) -> bool { + depth <= 4 && e.els().any(|c| matches!(local(&c.name), "maligngroup" | "malignmark") || has_align_mark(c, depth + 1)) +} + +fn nary(chr: char, lim_loc: LimLoc, sub: Arg, sup: Arg) -> MNode { + let lim_loc = Some(lim_loc).filter(|l| *l != if is_integral(chr) { LimLoc::SubSup } else { LimLoc::UndOvr }); + MNode::Nary { chr, lim_loc, grow: false, sub_hide: sub.is_empty(), sup_hide: sup.is_empty(), sub, sup, e: Vec::new() } +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::math::parse_linear; + + fn ml(linear: &str) -> String { + to_mathml(&parse_linear(linear), false, "") + } + + #[test] + fn runs_split_into_identifiers_numbers_and_operators() { + let m = ml("y=2x"); + assert!(m.starts_with(""), "{m}"); + assert!(m.contains("y=2x"), "{m}"); + assert!(m.contains("y=2x"), "{m}"); + assert!(to_mathml(&parse_linear("x"), true, "").contains("display=\"block\"")); + } + + #[test] + fn structures_become_their_elements() { + for (linear, want) in [ + ("a/b", "ab"), + ("x^2", "x2"), + ("x_s", "xs"), + ("x_i^2", ""), + ("√x", "x"), + ("√(3&x)", "x3"), + ("∑_(i=1)^n▒i", "∑"), + ("∫_0^1▒x", "∫"), + ("(a+b)", "("), + ("sin⁡x", "sin\u{2061}"), + ("■(a&b@c&d)", "ab"), + ] { + let m = ml(linear); + assert!(m.contains(want), "{linear}: {m}"); + } + let acc = to_mathml(&[MNode::Acc { chr: '\u{0302}', e: parse_linear("a") }], false, ""); + assert!(acc.contains("a^"), "{acc}"); + let bar = to_mathml(&[MNode::Bar { top: true, e: parse_linear("a") }], false, ""); + assert!(bar.contains("\u{203E}"), "{bar}"); + let lim = to_mathml(&[MNode::Lim { upper: false, e: parse_linear("lim"), lim: parse_linear("n→∞") }], false, ""); + assert!(lim.contains("limn→∞"), "{lim}"); + let bb = to_mathml(&[MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: parse_linear("a") }], false, ""); + assert!(bb.contains(""), "{bb}"); + 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}"); + 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, ""); + assert!(ph.contains(""), "{ph}"); + let styled = to_mathml( + &[ + MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }), + MNode::Run(MRun::plain("d")), + MNode::Run(MRun { text: "speed".into(), nor: true, ..Default::default() }), + ], + false, + "", + ); + assert!( + styled.contains("ℝ") && styled.contains("d") && styled.contains("speed"), + "{styled}" + ); + // Text is escaped. + assert!(ml("a<")); + } + + fn pasted(html: &str) -> Vec<(String, bool)> { + let flow = crate::html::parse(html); + let mut out = Vec::new(); + for b in &flow.blocks { + if let crate::model::FBlock::Para(p) = b { + for i in &p.inlines { + if let crate::model::Inline::Equation { linear, display } = i { + out.push((linear.clone(), *display)); + } + } + } + } + out + } + + #[test] + fn html_export_round_trips_equations() { + for (linear, display) in [("y=2x_s", false), ("x=(-b±√(b^2-4ac))/2a", true), ("∑_(k=1)^n▒k", false), ("■(a&b@c&d)", false)] { + let html = crate::html::inlines_html(&[crate::model::Inline::Equation { linear: linear.into(), display }]); + assert!(html.starts_with("Before {html} after

")), vec![(linear.to_string(), display)], "{html}"); + } + } + + #[test] + fn foreign_mathml_is_read_from_its_elements() { + let cases = [ + ("y=2xs", "y=2x_s"), + ("ab+1", "a/(b+1)"), + ("x", "√x"), + ("∑k=1nk", "∑_(k=1)^n▒k"), + ("ex", "e^x"), + // A TeX annotation is used when the presentation is empty. + ("\\frac{1}{2}", "1/2"), + ]; + for (html, want) in cases { + let got = pasted(html); + assert_eq!(got.first().map(|g| g.0.as_str()), Some(want), "{html}"); + } + assert!(pasted("x").first().is_some_and(|g| g.1)); + // Text after the equation still arrives; an unclosed element ends with the input. + let f = crate::html::parse("

x tail

"); + assert!(crate::model::to_doc(&f).plain_text(wordcraft_doc::StoryRef::Body).contains("tail")); + assert!(!pasted("

x").is_empty()); + // Hostile nesting is bounded. + let deep = format!("{}x{}", "".repeat(5000), "".repeat(5000)); + assert!(pasted(&deep).len() <= 1); + } + + #[test] + fn math_over_the_token_cap_is_skipped_to_its_end() { + // Past the cap, the rest of the element is dropped, not read as body text. + let html = format!("

{}z tail

", "y".repeat(MAX_NODES * 2)); + let flow = crate::html::parse(&html); + let mut text = String::new(); + for b in &flow.blocks { + if let crate::model::FBlock::Para(p) = b { + for i in &p.inlines { + if let crate::model::Inline::Text(t, _) = i { + text.push_str(t); + } + } + } + } + assert_eq!(text.trim(), "tail"); + } + + #[test] + fn function_names_apply_without_the_invisible_operator() { + let cases = [ + ("sinx", "sin\u{2061}x"), + ("logab", "log\u{2061}ab"), + ("cos\u{2061}x", "cos\u{2061}x"), + ("abx", "abx"), + ]; + for (html, want) in cases { + assert_eq!(pasted(html).first().map(|g| g.0.as_str()), Some(want), "{html}"); + } + let tree = to_tree(&El { + name: "math".into(), + attrs: vec![], + children: vec![ + Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("sin".into())] }), + Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("x".into())] }), + ], + }); + assert!(matches!(tree.as_slice(), [MNode::Func { .. }]), "{tree:?}"); + } + + #[test] + fn one_column_tables_are_column_vectors_unless_marked() { + let tree = |html: &str| { + let linear = pasted(html).first().map(|g| g.0.clone()).unwrap_or_default(); + wordcraft_doc::math::parse_linear(&linear) + }; + let vector = tree("ab"); + assert!(matches!(vector.as_slice(), [MNode::Matrix { rows, .. }] if rows.len() == 2), "{vector:?}"); + for marked in [ + "ab", + "a=1b", + ] { + let t = tree(marked); + assert!(matches!(t.as_slice(), [MNode::EqArr { rows }] if rows.len() == 2), "{marked}: {t:?}"); + } + // Our own equation arrays read back as equation arrays from their elements alone. + let ml = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x=1"), parse_linear("y=2")] }], false, ""); + let ml = ml.replace("application/x-unicodemath", "x-ignored"); + let t = tree(&ml); + assert!(matches!(t.as_slice(), [MNode::EqArr { .. }]), "{ml}: {t:?}"); + } + + #[test] + fn deep_nesting_is_bounded() { + let mut n = parse_linear("x"); + for _ in 0..500 { + n = vec![MNode::Frac { kind: FracKind::Bar, num: n, den: parse_linear("y") }]; + } + let m = to_mathml(&n, false, "z"); + assert!(m.ends_with("
")); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 10b569dac..49860fe0a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2494,10 +2494,8 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) { } item(ui, app, "Cut", "edit.cut", json!({})); if ui.add(egui::Button::new(tl!("Copy")).shortcut_text(crate::widgets::shortcut_text(app, "edit.copy"))).clicked() { - if let Ok(r) = app.run("edit.copy", json!({})) - && let Some(t) = r.get("text").and_then(|t| t.as_str()) - { - ui.ctx().copy_text(t.to_string()); + if let Ok(r) = app.run("edit.copy", json!({})) { + crate::system_clipboard::put_result(app, ui.ctx(), &r); } ui.close(); } diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 15ae9d195..cde1ffd7c 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -315,11 +315,8 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } egui::Event::Copy | egui::Event::Cut => { let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" }; - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(Value::as_str) - && !t.is_empty() - { - ctx.copy_text(t.to_string()); + if let Ok(r) = app.run(id, json!({})) { + crate::system_clipboard::put_result(app, ctx, &r); } } egui::Event::Key { key, pressed: true, modifiers, .. } => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d4a71ed3..af0385e80 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -131,6 +131,10 @@ pub struct Services { /// Desktop: the HTML on the system clipboard — another program's formatted copy (a Word /// document, a web page) — if any. Paste keeps its formatting (#687); see [`system_clipboard`]. pub clipboard_html: Option Option>>, + /// Desktop: put HTML on the system clipboard with its plain-text alternative (html, text), for + /// a copy holding an equation (as MathML, #816). `true` when both are there; otherwise the + /// copy's text goes through egui as usual. + pub set_clipboard_html: Option bool>>, /// Desktop: the RTF on the system clipboard, where the platform offers it (#687). pub clipboard_rtf: Option Option>>, /// Desktop: the names of the formats on the system clipboard (MIME types, Windows format diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6eae2b71f..b52d210b2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -528,7 +528,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false); let r = small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false); if r.clicked() { - ui.ctx().copy_text(app.session.clipboard_text.clone()); + crate::system_clipboard::put(app, ui.ctx(), &app.session.clipboard_text, &app.session.clipboard_html); } let active = app.session.painter.is_some(); small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), active); diff --git a/crates/ui-egui/src/system_clipboard.rs b/crates/ui-egui/src/system_clipboard.rs index e11436afd..be77f80ff 100644 --- a/crates/ui-egui/src/system_clipboard.rs +++ b/crates/ui-egui/src/system_clipboard.rs @@ -67,6 +67,25 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option bool { let n = name.trim(); @@ -221,4 +240,33 @@ mod tests { let e = app(unreadable).run("edit.paste", json!({})).unwrap_err(); assert!(e.contains("couldn't be read") && e.contains("isn't supported"), "{e}"); } + + #[test] + fn copying_an_equation_puts_its_html_on_the_system_clipboard() { + // #816: the host gets HTML with the equation as MathML beside the text. + let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, String)>::new())); + let sink = got.clone(); + let mut a = app(Services { + set_clipboard_html: Some(Box::new(move |h, t| { + sink.borrow_mut().push((h.to_string(), t.to_string())); + true + })), + ..Default::default() + }); + a.run("document.setText", json!({"text": "Speed "})).unwrap(); + a.run("caret.docEnd", json!({})).unwrap(); + a.run("insert.equation", json!({"linear": "y=2x_s", "edit": false})).unwrap(); + a.run("select.all", json!({})).unwrap(); + let ctx = egui::Context::default(); + let r = a.run("edit.copy", json!({})).unwrap(); + put_result(&a, &ctx, &r); + let got_now = got.borrow().clone(); + assert_eq!(got_now.len(), 1); + assert!(got_now[0].0.contains(" **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: Remove personal information and Remove date and time honoured on save, #1062; previously trivial: undrawn chart types show a placeholder, #998; previously trivial: saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; 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-13 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -28,7 +28,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | -| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) | +| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | @@ -166,6 +166,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-12 | trivial | VML text boxes keep their internal margins (`inset`), fit to their text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`); hidden drawings (`visibility:hidden`, `wp:docPr/@hidden`) are kept but not shown | | 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | +| 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | diff --git a/docs/gaps.md b/docs/gaps.md index 90eed0751..a85a8f0ee 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-12 · **Change:** trivial (sample-text formulas on Enter, #1067; previously trivial: radar, stock and other undrawn chart types show a placeholder instead of blank space, #998; previously trivial: saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; 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-13 · **Change:** 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. @@ -43,7 +43,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | -| 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | +| 29 | **Equations**: ink equations; copied equations as OMML for Word (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816; Word/LibreOffice paste not yet verified); only copies containing an equation get HTML, text-only copies stay plain | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | **Beta needs #1–#7** (~150–230 h with the stability and layout work they imply): they are the blocking gaps, and closing them lifts file formats, layout, stability and UI enough to take the @@ -63,7 +63,10 @@ The same gaps grouped the way the parity documents are, for agents working in on styles, and Draw Table and Eraser (#303) landed on 2026-10-10. Paste from other programs keeps their formatting (system clipboard HTML/RTF), Merge Formatting keeps emphasis, Paste Special lists what the system clipboard holds, and Linux/Wayland clipboard pictures paste (#687, #833); - still missing: the Paste Options button after a paste, and RTF on macOS and X11. + still missing: the Paste Options button after a paste, and RTF on macOS and X11. Copying an + equation puts MathML in HTML on the clipboard (#816; pasting into Word/LibreOffice not yet + verified; OMML on the clipboard is #1297); only copies containing an equation get HTML, + text-only copies stay plain text. - **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); context menus for objects (the table's landed; it still lacks Border Styles); Linux title bar theming (#78); File › Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real @@ -141,6 +144,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | trivial | #10: Tight and Through wrapping follow the object's outline (#354) | | 2026-10-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright | | 2026-10-10 | trivial | #10: Drawing Canvas landed (#344); in-canvas dragging of members remains | +| 2026-10-13 | trivial | #29: copied equations reach the clipboard as MathML in HTML (#816); OMML for Word stays open (#1297) | | 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) | | 2026-10-10 | trivial | #3: Windows lag on GPUs without a DirectX 12 driver fixed with an OpenGL fallback (#316) | | 2026-10-10 | minor | #8: Polish proofing (spelling, grammar, hyphenation) and a proofing language per run landed; the gap is now the other languages (20–40 h) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d93f1068..b60bbffd7 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-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: a chosen interface size is absolute and Auto keeps the display scale, #822; 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-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: copied equations reach other programs as MathML, #816; 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 @@ -49,6 +49,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | +| 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | From ff15dc451dfea457ee5ee11cddf5f46d76e78eab Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:05:06 +0200 Subject: [PATCH 032/199] Equation pictures share a few atlas textures instead of hundreds (#1002) (#1149) * #1002: equation pictures share one texture, keeping theme colours on Windows The Equation tab's gallery tiles, structure buttons and symbols were a texture each: hundreds of small textures, created as galleries opened and all freed at once when the preview cache filled. On Windows (DirectX 12) tiles then showed other textures' memory (inverted pictures, red and teal fills, damaged interface letters). They are now packed into one atlas texture written in place, the way egui keeps its glyphs; it starts over when full (at most once a frame). Tests: every equation picture composited in both themes is only the theme's greys; all pictures share one texture, none freed while browsing, none overlapping; the atlas packs rows and starts over when full. Co-Authored-By: Claude Opus 5.5 * #1002: the theme test looks for paper anywhere, not in the corner (Windows fonts reach it) Co-Authored-By: Claude Opus 5.5 * #1002: equation atlas uses up to four pages and never wipes a page drawn from this frame Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/ui-egui/src/equation_tab.rs | 6 +- crates/ui-egui/src/previews.rs | 324 ++++++++++++++++++++++++++++- docs/ui-parity.md | 3 +- 4 files changed, 328 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0500b70bf..a5b5cbbfe 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-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: equations copy as MathML, #816; previously trivial: progress log: 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-13 · **Change:** 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. @@ -147,6 +147,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged | | 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | +| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -294,6 +295,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | +| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | | 2026-10-11 | trivial | Progress log: the table context menu | diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 7a28a0908..43ca8ab8a 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -14,7 +14,7 @@ use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, LABEL_PITCH, group, small}; /// What each structure button shows. -fn button_picture(id: &str) -> &'static str { +pub(crate) fn button_picture(id: &str) -> &'static str { match id { "fraction" => "x/y", "script" => "e^x", @@ -35,8 +35,8 @@ fn button_picture(id: &str) -> &'static str { fn paint_math(app: &mut WordApp, ui: &Ui, r: Rect, key: &str, linear: &str, nodes: Option, display: bool, pt: f32) { let nodes = nodes.unwrap_or_else(|| parse_linear(linear)); match crate::previews::math_texture(app, ui.ctx(), key, &nodes, display, pt, r.size()) { - Some(tex) => { - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Tokens::get(ui.ctx()).text); + Some((tex, uv)) => { + ui.painter().image(tex, r, uv, Tokens::get(ui.ctx()).text); } None => { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 548c08a8b..528e95f5e 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -26,6 +26,129 @@ pub struct Previews { /// Previews rendered this frame (rendering is spread over frames). budget: std::cell::Cell, frame: u64, + /// The Equation tab's pictures, packed into one texture (#1002). + math: MathAtlas, +} + +/// Side of each square texture page the equation pictures share, in pixels. +const MATH_ATLAS: usize = 2048; +/// At most this many pages: enough for a frame's pictures at a high display scale (the 22 +/// built-in equations alone take two pages at 3×), still a handful of textures in all. +const MATH_ATLAS_PAGES: usize = 4; +/// Transparent pixels between pictures, so linear filtering never bleeds one into the next. +const MATH_ATLAS_GAP: usize = 2; + +/// Every equation picture (gallery tiles, structure buttons, symbols) packed into a few shared +/// textures ("pages"), row by row, and drawn as a part of one (#1002). +/// +/// They used to be a texture each: hundreds of small textures, created as galleries opened and +/// all freed at once when the preview cache filled. On Windows (DirectX 12) the tiles then +/// showed other textures' memory: inverted pictures, red and teal fills, and even damaged +/// interface letters. A texture written in place is what egui does for its own glyphs; the +/// atlas creates at most [`MATH_ATLAS_PAGES`] textures and frees none while galleries are +/// browsed. When every page is full, the page least recently drawn from starts over, but never +/// one drawn from in the current frame (its pictures are already on screen): a frame whose +/// pictures don't fit shows the rest as linear text. +#[derive(Default)] +struct MathAtlas { + pages: Vec, + /// Picture key → its page and part of that page's texture (UV coordinates). + uv: HashMap, + /// How many times a page started over (for tests). + wipes: u64, +} + +#[derive(Default)] +struct AtlasPage { + tex: Option, + /// Where the next picture goes: the current row's left edge and top, and the row's height. + x: usize, + y: usize, + row_h: usize, + /// The last frame a picture of this page was handed out in. + used: u64, +} + +impl AtlasPage { + /// A free spot for a `w`×`h` picture, or `None` when the page is full or it can't fit. + fn place(&mut self, w: usize, h: usize) -> Option<[usize; 2]> { + if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS { + return None; + } + let (mut x, mut y, mut row_h) = (self.x, self.y, self.row_h); + if x.saturating_add(w) > MATH_ATLAS { + x = 0; + y = y.saturating_add(row_h).saturating_add(MATH_ATLAS_GAP); + row_h = 0; + } + if y.saturating_add(h) > MATH_ATLAS { + return None; + } + self.x = x.saturating_add(w).saturating_add(MATH_ATLAS_GAP); + self.y = y; + self.row_h = row_h.max(h); + Some([x, y]) + } + + /// Forget the page's layout and clear its texture. + fn reset(&mut self) { + self.x = 0; + self.y = 0; + self.row_h = 0; + if let Some(tex) = &mut self.tex { + tex.set(egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR); + } + } +} + +impl MathAtlas { + /// The picture stored under `key`, marking its page as drawn from in `frame`. + fn get(&mut self, frame: u64, key: &str) -> Option<(egui::TextureId, Rect)> { + let (page, uv) = *self.uv.get(key)?; + let p = self.pages.get_mut(page)?; + let id = p.tex.as_ref()?.id(); + p.used = frame; + Some((id, uv)) + } + + /// Add `img` under `key`; its texture and UV rectangle, or `None` if it can't be placed in + /// this frame without wiping pictures already handed out in it. + fn add(&mut self, ctx: &egui::Context, frame: u64, key: &str, img: egui::ColorImage) -> Option<(egui::TextureId, Rect)> { + let [w, h] = img.size; + if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS { + return None; + } + let mut spot = self.pages.iter_mut().enumerate().find_map(|(i, p)| p.place(w, h).map(|at| (i, at))); + if spot.is_none() && self.pages.len() < MATH_ATLAS_PAGES { + let mut p = AtlasPage::default(); + spot = p.place(w, h).map(|at| (self.pages.len(), at)); + self.pages.push(p); + } + if spot.is_none() { + // Every page is full: start over the one drawn from longest ago, unless all of them + // were drawn from in this frame. + let (i, p) = self.pages.iter_mut().enumerate().filter(|(_, p)| p.used != frame).min_by_key(|(_, p)| p.used)?; + p.reset(); + self.uv.retain(|_, (page, _)| *page != i); + self.wipes = self.wipes.saturating_add(1); + spot = p.place(w, h).map(|at| (i, at)); + } + let (i, at) = spot?; + let p = self.pages.get_mut(i)?; + let tex = p.tex.get_or_insert_with(|| { + ctx.load_texture( + format!("math_atlas_{i}"), + egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT), + egui::TextureOptions::LINEAR, + ) + }); + tex.set_partial(at, img, egui::TextureOptions::LINEAR); + p.used = frame; + let s = MATH_ATLAS as f32; + let uv = Rect::from_min_max(pos2(at[0] as f32 / s, at[1] as f32 / s), pos2((at[0] + w) as f32 / s, (at[1] + h) as f32 / s)); + self.uv.insert(key.to_string(), (i, uv)); + Some((tex.id(), uv)) + } } impl Previews { @@ -182,8 +305,9 @@ impl Previews { } /// An equation rasterised for a gallery tile or a button (`pt`: font size; `size`: the tile in -/// points), with its empty slots shown. Cached by `key`; `None` while waiting for this frame's -/// rendering budget. +/// points), with its empty slots shown: the shared texture and the picture's UV rectangle in it +/// ([`MathAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting +/// for this frame's rendering budget. pub fn math_texture( app: &mut WordApp, ctx: &egui::Context, @@ -192,10 +316,25 @@ pub fn math_texture( display: bool, pt: f32, size: egui::Vec2, -) -> Option { +) -> Option<(egui::TextureId, Rect)> { let ppp = ctx.pixels_per_point(); let k = format!("math:{key}:{pt}:{}x{}:{ppp}", size.x, size.y); - app.previews.get_or(ctx, &k, || math_image(nodes, display, pt, size.x, size.y, ppp)) + let p = &mut app.previews; + let frame = ctx.cumulative_frame_nr(); + if let Some(hit) = p.math.get(frame, &k) { + return Some(hit); + } + if frame != p.frame { + p.frame = frame; + p.budget.set(0); + } + if p.budget.get() > 8 { + ctx.request_repaint(); + return None; + } + p.budget.set(p.budget.get() + 1); + let img = math_image(nodes, display, pt, size.x, size.y, ppp)?; + p.math.add(ctx, frame, &k, img) } fn math_image(nodes: &wordcraft_doc::math::Arg, display: bool, pt: f32, w: f32, h: f32, ppp: f32) -> Option { @@ -603,6 +742,183 @@ mod tests { assert!(img.pixels.iter().all(|p| *p == egui::Color32::from_white_alpha(p.a())), "ink is white, to be tinted"); } + /// Every picture the Equation tab and Insert › Equation draw: (key, nodes, display, pt, tile size). + fn equation_pictures() -> Vec<(String, wordcraft_doc::math::Arg, bool, f32, egui::Vec2)> { + use wordcraft_doc::math::{MNode, MRun, parse_linear}; + use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS}; + let mut out = Vec::new(); + for g in STRUCTURES { + let pic = crate::equation_tab::button_picture(g.id); + out.push((format!("btn:{pic}"), parse_linear(pic), false, 12.0, vec2(40.0, 32.0))); + for (_, templates) in g.sections { + for t in templates.iter() { + out.push((format!("tpl:{}", t.id), t.nodes(), true, 14.0, vec2(56.0, 52.0))); + out.push((format!("tpl:{}", t.id), t.nodes(), true, 13.0, vec2(154.0, 56.0))); + } + } + } + for (id, _, lin) in BUILT_INS { + out.push((format!("builtin:{id}"), parse_linear(lin), true, 11.0, vec2(346.0, 60.0))); + } + for (_, chars) in SYMBOL_SETS { + for c in chars.chars() { + let nodes = vec![MNode::Run(MRun { text: c.to_string(), lit: true, ..Default::default() })]; + out.push((format!("sym:{c}"), nodes, false, 11.0, vec2(22.0, 22.0))); + } + } + out + } + + /// #1002: drawn the way the Equation tab draws them (the picture tinted with the theme's text + /// colour on an input-coloured tile), every gallery tile, button and symbol is the theme's + /// greys only: its background is the tile colour and its ink no darker or lighter than the + /// text, in light and dark mode, with no red or teal anywhere. + #[test] + fn equation_pictures_follow_the_theme() { + let mix = |fg: u8, bg: u8, a: u8| ((u32::from(fg) * u32::from(a) + u32::from(bg) * (255 - u32::from(a))) / 255) as u8; + for (key, nodes, display, pt, size) in equation_pictures() { + let img = math_image(&nodes, display, pt, size.x, size.y, 1.0).unwrap_or_else(|| panic!("{key} renders")); + assert!(img.pixels.iter().all(|p| *p == egui::Color32::from_white_alpha(p.a())), "{key}: an ink mask"); + // Ink may reach the corner (a matrix bracket with Windows fonts): look for paper anywhere. + assert!(img.pixels.iter().any(|p| p.a() == 0), "{key}: transparent paper"); + for t in [Tokens::light(), Tokens::dark()] { + let (bg, fg) = (t.input, t.text); + let (lo, hi) = (bg.r().min(fg.r()), bg.r().max(fg.r())); + for p in &img.pixels { + let a = p.a(); + let c = [mix(fg.r(), bg.r(), a), mix(fg.g(), bg.g(), a), mix(fg.b(), bg.b(), a)]; + assert!(c[0] == c[1] && c[1] == c[2], "{key}: a grey, not {c:?}"); + assert!((lo..=hi).contains(&c[0]), "{key}: between the tile and the text colour, not {c:?}"); + } + } + } + } + + /// #1002: the pictures share one texture, written in place: browsing every gallery creates no + /// texture per tile and frees none (hundreds of small textures, freed in bulk, showed other + /// textures' memory on DirectX 12). + #[test] + fn equation_pictures_share_one_texture() { + let ctx = egui::Context::default(); + let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default()); + let pictures = equation_pictures(); + // Symbols repeat across sets (∀, ≠…): each is drawn once. + let unique = pictures.iter().map(|(key, _, _, pt, size)| format!("{key}:{pt}:{size:?}")).collect::>().len(); + let mut drawn: HashMap = HashMap::new(); + let mut freed = 0; + for _ in 0..200 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let ctx = ui.ctx().clone(); + for (key, nodes, display, pt, size) in &pictures { + let k = format!("{key}:{pt}:{size:?}"); + if let std::collections::hash_map::Entry::Vacant(e) = drawn.entry(k) + && let Some(t) = math_texture(&mut app, &ctx, key, nodes, *display, *pt, *size) + { + e.insert(t); + } + } + }); + freed += out.textures_delta.free.len(); + out.textures_delta.clear(); + if drawn.len() == unique { + break; + } + } + assert_eq!(drawn.len(), unique, "every picture is drawn"); + let ids: std::collections::HashSet<_> = drawn.values().map(|(id, _)| *id).collect(); + assert_eq!(ids.len(), 1, "one texture for every picture"); + assert_eq!(freed, 0, "no texture freed while browsing"); + // No two pictures overlap, and each lies inside the texture. + let rects: Vec = drawn.values().map(|(_, uv)| *uv).collect(); + for (i, a) in rects.iter().enumerate() { + assert!(a.min.x >= 0.0 && a.min.y >= 0.0 && a.max.x <= 1.0 && a.max.y <= 1.0, "{a:?} inside"); + for b in rects.iter().skip(i + 1) { + assert!(!a.intersects(*b) || a.intersect(*b).area() <= 0.0, "{a:?} overlaps {b:?}"); + } + } + } + + /// #1002: pictures are packed row by row, and pictures that can never fit are refused. + #[test] + fn math_atlas_packs_rows() { + let mut a = AtlasPage::default(); + assert_eq!(a.place(100, 40), Some([0, 0])); + assert_eq!(a.place(100, 20), Some([100 + MATH_ATLAS_GAP, 0])); + assert_eq!(a.place(MATH_ATLAS, 10), Some([0, 40 + MATH_ATLAS_GAP]), "a new row under the tallest"); + assert_eq!(a.place(MATH_ATLAS + 1, 10), None); + assert_eq!(a.place(10, 0), None); + assert_eq!(a.place(10, MATH_ATLAS), None, "full"); + assert_eq!(a.place(10, 10), Some([0, 40 + MATH_ATLAS_GAP + 10 + MATH_ATLAS_GAP]), "still room after a refusal"); + } + + /// #1002: a page is never wiped in a frame that handed out one of its pictures (they are + /// already on screen); once all pages are full, the least recently drawn page starts over. + #[test] + fn math_atlas_never_wipes_a_page_drawn_from_this_frame() { + let ctx = egui::Context::default(); + let img = || egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let mut a = MathAtlas::default(); + let first: Vec<_> = (0..MATH_ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect(); + assert!(first.iter().all(Option::is_some), "a page each"); + let ids: std::collections::HashSet<_> = first.iter().flatten().map(|(id, _)| *id).collect(); + assert_eq!(ids.len(), MATH_ATLAS_PAGES); + assert!(a.add(ui.ctx(), 1, "more", img()).is_none(), "every page was drawn from this frame"); + assert_eq!(a.wipes, 0); + assert!(a.uv.len() == MATH_ATLAS_PAGES, "nothing forgotten"); + // Next frame: page 0 is drawn from again, so the one drawn longest ago is page 1. + assert_eq!(a.get(2, "p0"), first.first().copied().flatten()); + let (id, _) = a.add(ui.ctx(), 2, "more", img()).expect("a page starts over"); + assert_eq!(a.wipes, 1); + assert_eq!(Some(id), first.get(1).copied().flatten().map(|(id, _)| id), "the least recently drawn page"); + assert!(a.uv.contains_key("p0") && !a.uv.contains_key("p1")); + // Pages 0 and 1 are now in use this frame; 2 and 3 are free to start over, then none. + assert!(a.add(ui.ctx(), 2, "x", img()).is_some()); + assert!(a.add(ui.ctx(), 2, "y", img()).is_some()); + assert!(a.add(ui.ctx(), 2, "z", img()).is_none()); + assert_eq!(a.wipes, 3); + assert!(a.get(2, "p0").is_some() && a.get(2, "more").is_some() && a.get(2, "x").is_some() && a.get(2, "y").is_some()); + }); + out.textures_delta.clear(); + } + + /// #1002: at a display scale of 3, the 22 built-in equations need more than one page; the + /// built-in menu opened again draws them all from the atlas, with nothing wiped or moved + /// (a single page used to start over every frame: flicker and constant re-rendering). + #[test] + fn builtin_menu_at_scale_3_fits_without_wiping() { + use wordcraft_doc::math::parse_linear; + use wordcraft_engine::math_gallery::BUILT_INS; + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(3.0); + let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default()); + let builtins: Vec<_> = BUILT_INS.iter().map(|(id, _, lin)| (format!("builtin:{id}"), parse_linear(lin))).collect(); + let draw = |app: &mut WordApp| { + let mut got = Vec::new(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + assert_eq!(ui.ctx().pixels_per_point(), 3.0); + let ctx = ui.ctx().clone(); + got = builtins.iter().map(|(key, nodes)| math_texture(app, &ctx, key, nodes, true, 11.0, vec2(346.0, 60.0))).collect(); + }); + out.textures_delta.clear(); + got + }; + // The first opening renders a few a frame. + let mut first = draw(&mut app); + for _ in 0..20 { + if first.iter().all(Option::is_some) { + break; + } + first = draw(&mut app); + } + assert!(first.iter().all(Option::is_some), "every built-in drawn"); + let wipes = app.previews.math.wipes; + let again = draw(&mut app); + assert_eq!(again, first, "the same texture and place for each"); + assert_eq!(app.previews.math.wipes, wipes, "nothing started over"); + assert_eq!(wipes, 0); + } + #[test] fn ink_mask_keeps_the_ink_and_drops_the_paper() { let mut img = egui::ColorImage::new([3, 1], vec![egui::Color32::WHITE, egui::Color32::BLACK, egui::Color32::from_gray(128)]); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b60bbffd7..843b08741 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-13 · **Change:** trivial (no document behind the Start screen, #1129; previously trivial: copied equations reach other programs as MathML, #816; 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-13 · **Change:** 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 @@ -102,6 +102,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Menu buttons (Header, Page Number, Breaks, Wrap Text, table Select/Delete/AutoFit…) grey out and don't open while the command their menu is about is disabled, like the other ribbon buttons (#658) | | 2026-10-11 | trivial | One drop-down marker everywhere: large ribbon buttons, Equation and the Editing/Reviewing button draw the `dropdown` icon after their label, like split and menu buttons, instead of a text glyph (#659) | | 2026-10-11 | trivial | Dark theme: Styles, style-set and table-style previews sit on a dimmer page tone; with View › Switch Modes on, the Styles gallery previews the dark page (#663) | +| 2026-10-13 | trivial | Equation tab and Insert › Equation: every gallery tile, structure button and symbol is drawn from a few shared textures written in place (at most four, never wiped while their pictures are on screen), so the pictures stay in the theme's tile and text colours on Windows (DirectX 12) instead of turning inverted, red or teal (#1002) | | 2026-10-11 | trivial | Dialog primary buttons, the Discord pill, the table eraser line and the Navigation search highlight take their colours from the theme in both themes (`widgets::primary_button`); a test bans the old fixed values (#656) | | 2026-10-11 | trivial | Dark theme: Share, the avatar initials, the AutoSave knob and keytips use dark `on_accent` text on the light accent (6.2:1, 8.4:1 on hover; white was 2.9:1) (#655) | | 2026-10-11 | trivial | Text field and combo outlines reach 3:1 against their fill and surroundings in both themes (light #8A8A8A, dark #7A7A7A); a test pins the ratios (#657) | From 04f6f53b8a4d8140c7bb62f949b0a6099a473da1 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:05:18 +0200 Subject: [PATCH 033/199] Read Aloud starts PowerShell without a console window (#1122) (#1150) * #1122: Read Aloud starts PowerShell without a console window On Windows the speech program is PowerShell, a console program: each sentence it spoke flashed a console window over the document. It now starts with CREATE_NO_WINDOW. Co-Authored-By: Claude Opus 5.5 * #1122: the console-window test checks the flags Read Aloud passes windows_speech builds the PowerShell program, arguments and creation flags that speak() turns into its Command; std can't read creation flags back from a Command, so the test checks that value (CREATE_NO_WINDOW) and the arguments. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/speech.rs | 54 ++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index 9530c8197..ce996075e 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -505,6 +505,36 @@ fn windows_powershell(system_root: Option) -> std::path::Pat std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe") } +/// Win32 `CREATE_NO_WINDOW` process creation flag: a console program starts without a console window. +#[cfg(any(target_os = "windows", test))] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// How Read Aloud starts Windows PowerShell to speak at `rate`: the program, its arguments and +/// its process creation flags. `speak` builds its `Command` from exactly this; std can't read +/// creation flags back from a `Command`, so the tests check this value instead. +#[cfg(any(target_os = "windows", test))] +struct WindowsSpeech { + program: std::path::PathBuf, + args: [String; 4], + creation_flags: u32, +} + +#[cfg(any(target_os = "windows", test))] +fn windows_speech(system_root: Option, rate: f32) -> WindowsSpeech { + // SAPI rate: -10 (a third of normal speed) … 10 (three times). + let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64; + let script = format!( + "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; $s.Speak([Console]::In.ReadToEnd())" + ); + WindowsSpeech { + program: windows_powershell(system_root), + args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script], + // PowerShell is a console program: without this, every sentence flashes a console window + // over the document (#1122). + creation_flags: CREATE_NO_WINDOW, + } +} + /// Speak one sentence with the system's speech program, stopping early when `changed()`. #[cfg(not(target_arch = "wasm32"))] fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { @@ -521,13 +551,10 @@ fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { } #[cfg(target_os = "windows")] { - // SAPI rate: -10 (a third of normal speed) … 10 (three times). - let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64; - let script = format!( - "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; $s.Speak([Console]::In.ReadToEnd())" - ); - let mut c = Command::new(windows_powershell(std::env::var_os("SystemRoot"))); - c.args(["-NoProfile", "-NonInteractive", "-Command", &script]); + let w = windows_speech(std::env::var_os("SystemRoot"), rate); + let mut c = Command::new(&w.program); + c.args(&w.args); + std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags); tries.push(c); } #[cfg(all(unix, not(target_os = "macos")))] @@ -592,6 +619,19 @@ mod tests { } } + #[test] + fn powershell_starts_without_a_console_window() { + // The flags `speak` passes to `creation_flags` (std can't read them back from the + // `Command`): Win32's CREATE_NO_WINDOW (0x0800_0000), or every sentence flashes a + // console (#1122). + let w = windows_speech(Some("D:\\Win".into()), 1.0); + assert_eq!(w.creation_flags, 0x0800_0000); + assert_eq!(w.program, windows_powershell(Some("D:\\Win".into()))); + assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]); + assert!(w.args[3].contains("$s.Rate = 0;"), "{}", w.args[3]); + assert!(windows_speech(None, 3.0).args[3].contains("$s.Rate = 10;")); + } + #[test] fn splits_sentences_but_not_abbreviations() { let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one."; From fc41918814f62645b6746ed68e71e8c2c2e85d6f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 09:07:25 -0400 Subject: [PATCH 034/199] Release 0.7.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae82a38c9..c5a92f472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5098,7 +5098,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.6.1" +version = "0.7.0" dependencies = [ "arboard", "clipboard-win", @@ -5130,7 +5130,7 @@ dependencies = [ [[package]] name = "wordcraft-cli" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde_json", "wordcraft-doc", @@ -5142,11 +5142,11 @@ dependencies = [ [[package]] name = "wordcraft-control-key" -version = "0.6.1" +version = "0.7.0" [[package]] name = "wordcraft-doc" -version = "0.6.1" +version = "0.7.0" dependencies = [ "proptest", "serde", @@ -5158,7 +5158,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.6.1" +version = "0.7.0" dependencies = [ "cfb", "image", @@ -5173,7 +5173,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.6.1" +version = "0.7.0" dependencies = [ "aes", "base64", @@ -5195,7 +5195,7 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.6.1" +version = "0.7.0" dependencies = [ "image", "js-sys", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.6.1" +version = "0.7.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.6.1" +version = "0.7.0" dependencies = [ "image", "log", @@ -5247,14 +5247,14 @@ dependencies = [ [[package]] name = "wordcraft-geom" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde", ] [[package]] name = "wordcraft-layout" -version = "0.6.1" +version = "0.7.0" dependencies = [ "image", "js-sys", @@ -5273,7 +5273,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.6.1" +version = "0.7.0" dependencies = [ "log", "serde_json", @@ -5285,7 +5285,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.6.1" +version = "0.7.0" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -5293,7 +5293,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.6.1" +version = "0.7.0" dependencies = [ "hayro-cmap", "hayro-interpret", @@ -5313,7 +5313,7 @@ dependencies = [ [[package]] name = "wordcraft-proof" -version = "0.6.1" +version = "0.7.0" dependencies = [ "log", "miniz_oxide 0.8.9", @@ -5322,7 +5322,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.6.1" +version = "0.7.0" dependencies = [ "image", "log", @@ -5336,7 +5336,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.6.1" +version = "0.7.0" dependencies = [ "craft-ui", "egui", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.6.1" +version = "0.7.0" dependencies = [ "eframe", "egui", @@ -5380,7 +5380,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.6.1" +version = "0.7.0" dependencies = [ "log", "proptest", @@ -5493,7 +5493,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.1" +version = "0.7.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 4e4f69915..ab1210428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"] [workspace.package] -version = "0.6.1" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 68cf67a6c6a372bbc9f0082491ed37388dcf1d62 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 18:42:37 +0200 Subject: [PATCH 035/199] CI: main passes again (Word 2003 check reads through io; first keystroke after a chrome click isn't dropped) (#1578) * Engine: the Word 2003 format check reads through io, so commands still never touch files themselves is_word_2003_file (#1081) opened the file with std::fs, which the file-access guard test (commands_do_not_touch_the_file_system_themselves) forbids outside io.rs; main failed it. A new io::read_file_head reads the first bytes inside the read root. Co-Authored-By: Claude Opus 5.5 * UI: a chrome click counts the page as focused at once, so the next keystroke isn't dropped; the ribbon-focus test passes again Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 10 +--------- crates/engine/src/io.rs | 12 ++++++++++++ crates/ui-egui/src/lib.rs | 3 +++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4ca72abe8..42ad9a7b2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -257,16 +257,8 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { /// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)? #[cfg(not(target_arch = "wasm32"))] fn is_word_2003_file(path: &std::path::Path) -> bool { - use std::io::Read; - if crate::file_access::check_read(&path.to_string_lossy()).is_err() { - return false; - } - let Ok(f) = std::fs::File::open(path) else { return false }; - let mut head = Vec::new(); // The root element and its namespaces come first. - if f.take(64 * 1024).read_to_end(&mut head).is_err() { - return false; - } + let Ok(head) = crate::io::read_file_head(&path.to_string_lossy(), 64 * 1024) else { return false }; wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003 } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index fe451ff96..cc35d9734 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -406,6 +406,18 @@ pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, Str std::fs::read(path).map_err(|e| format!("{path}: {e}")) } +/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation +/// read root when there is one: enough to tell a file's format by its start. +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file_head(path: &str, max: u64) -> Result, String> { + use std::io::Read; + crate::file_access::check_read(path)?; + let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let mut head = Vec::new(); + f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; + Ok(head) +} + /// Write `bytes` to the file at `path`, inside the automation write root when there is one. #[cfg(not(target_arch = "wasm32"))] pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af0385e80..86ac1bdd4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1646,6 +1646,9 @@ impl WordApp { let on_chrome = ctx.layer_id_at(pos).is_none_or(|l| l.order == egui::Order::Background); if on_chrome && ctx.memory(|m| m.focused().is_none()) && !egui::Popup::is_any_open(ctx) { self.canvas.want_focus = true; + // The canvas reads keys before it takes focus back (#1107), so it counts as focused + // now: a keystroke in the very next frame reaches the page instead of being dropped. + self.canvas.focused = true; ctx.request_repaint(); } } From 646349d521c810b31dd008b5b2c5212be8774c48 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 18:49:18 +0200 Subject: [PATCH 036/199] Interface-translation audit green and back in CI (#1128) (#1143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ({name} {height} × {width} in, {kind}, {columns} × {rows}, …) and use the region's decimal separator; the fixed-format paper rows dead since #838 are removed from six catalogs. - 20 strings added to all 18 catalogs: status-bar messages (table split and merge, ink replay, citations, SmartArt, Researcher, Design defaults), the Reading Mode toolbar, Go To, Search's Suggested, envelope paper names. - Audit: citation style names stay exempt as proper names; `bad parameters:` errors (JSON parameter names for scripts and MCP) stay English like the other engine errors; placeholder-only patterns need no entry. - Flakiness: the Read Aloud player drew the speech worker's error straight from shared state, so with no TTS engine an error landing between poll and draw was shown without being known to the app (the rare failure under load); it now draws the error poll saw. The audit holds Read Aloud (no speech thread, no sound) and gathers process-wide names (fonts, custom dictionary) every frame, since tests running alongside change them. #[ignore] removed. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 +++- crates/ui-egui/src/dialogs_layout.rs | 34 +++++++++++++++++++++------ crates/ui-egui/src/i18n/audit.rs | 35 ++++++++++++++++++++++++---- crates/ui-egui/src/i18n/bg.tsv | 31 +++++++++++++++++------- crates/ui-egui/src/i18n/cs.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/et.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/fr.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/hu.tsv | 31 +++++++++++++++++------- crates/ui-egui/src/i18n/it.tsv | 31 +++++++++++++++++------- crates/ui-egui/src/i18n/ja.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/pl.tsv | 31 +++++++++++++++++------- crates/ui-egui/src/i18n/pt-br.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/ru.tsv | 31 +++++++++++++++++------- crates/ui-egui/src/i18n/sr-latn.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/sr.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 31 +++++++++++++++++------- crates/ui-egui/src/i18n/uk.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 22 +++++++++++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 22 +++++++++++++++++ crates/ui-egui/src/read_aloud.rs | 6 +++-- crates/ui-egui/src/ribbon.rs | 9 +++++-- docs/localization-parity.md | 12 +++++++++- 24 files changed, 479 insertions(+), 71 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a5b5cbbfe..0b934414e 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-13 · **Change:** 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-13 · **Change:** trivial (progress log: interface audit back in CI, #1128; 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. @@ -282,6 +282,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-07 | v0.1.0 released; contributor credits in About (#6) | | 2026-10-06 | Layout arc: wrap around floats, text boxes, page borders, line numbers, row splitting, drop caps, hyphenation, comment balloons; release pipeline for every platform; first estimate (~62% real feature parity, 120–150 h to 100%) | | 2026-10-05 | M0–M12 first versions: model, layout, render, engine with 200+ commands, Word-style UI, DOCX I/O, PDF and other formats, references, review, mailings, CLI, MCP, web | +| 2026-10-13 | The interface-translation audit runs in CI again (#1128): the size labels of Envelopes, Labels and Layout › Size are translated (`4,13 × 9,5 дюйма`) and use the region's decimal separator, and status-bar messages (table split and merge, ink replay, citations, SmartArt, Researcher, Design defaults), the Reading Mode toolbar, Go To and Search's Suggested heading are translated in every interface language, and the Read Aloud player no longer shows a speech error a frame before the status bar does, the race behind the audit's rare failure under load | ## Revision history @@ -294,6 +295,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) | | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | +| 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index 55a3c58e2..bb44db833 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -526,16 +526,25 @@ fn envelopes_ui(app: &mut WordApp, ui: &mut Ui, f: &mut EnvelopeForm) -> bool { cancel } -/// `DL 110 × 220 mm` / `#10 4.13 × 9.5 in`: metric sizes in millimetres, the others in inches. +/// `DL 110 × 220 mm` / `#10 4.13 × 9.5 in`: metric sizes in millimetres, the others in +/// inches. The pattern is translated (the unit's name, the order) and the numbers use the +/// region's decimal separator. pub(crate) fn envelope_label(name: &str, w: f32, h: f32) -> String { let short = name.strip_prefix("Envelope ").unwrap_or(name); - if short.starts_with('#') || short == "Monarch" { + let (template, w, h) = if short.starts_with('#') || short == "Monarch" { let i = |pt: f32| ((pt / 72.0) * 100.0).round() / 100.0; - format!("{short} {} × {} in", i(h), i(w)) + (tl!("{name} {height} × {width} in"), i(w), i(h)) } else { let mm = |pt: f32| (pt * 25.4 / 72.0).round(); - format!("{short} {} × {} mm", mm(h), mm(w)) - } + (tl!("{name} {height} × {width} mm"), mm(w), mm(h)) + }; + crate::i18n::fmt(template, &[("name", short), ("height", &with_decimal(h.to_string())), ("width", &with_decimal(w.to_string()))]) +} + +/// A number written with `.` rewritten with the region's decimal separator (`2,625`). +fn with_decimal(n: String) -> String { + let sep = crate::options::decimal(); + if sep == '.' { n } else { n.replace('.', sep.encode_utf8(&mut [0; 4])) } } // --------------------------------------------------------------------------------------------- @@ -586,8 +595,19 @@ fn product_label(i: usize) -> String { "return" => tl!("Return address labels"), _ => tl!("Address labels"), }; - let paper = tl!(p.paper); - format!("{kind}, {} × {}, {} ({paper})", p.cols, p.rows, p.size_text()) + // Inches on Letter, millimetres on A4, as the sheets are sold. + let size = if p.paper == "A4" { + let mm = |pt: f32| with_decimal(format!("{:.1}", pt * 25.4 / 72.0)); + crate::i18n::fmt(tl!("{width} × {height} mm"), &[("width", &mm(p.width)), ("height", &mm(p.height))]) + } else { + let i = |pt: f32| with_decimal((((pt / 72.0) * 1000.0).round() / 1000.0).to_string()); + format!("{}\" × {}\"", i(p.width), i(p.height)) + }; + let (columns, rows) = (p.cols.to_string(), p.rows.to_string()); + crate::i18n::fmt( + tl!("{kind}, {columns} × {rows}, {size} ({paper})"), + &[("kind", kind), ("columns", &columns), ("rows", &rows), ("size", &size), ("paper", tl!(p.paper))], + ) } fn labels_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LabelsForm) -> bool { diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index de25080e9..bde2dab12 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -13,6 +13,17 @@ use crate::WordApp; thread_local! { // Input time, advanced every frame so tooltips and animations move on. static TIME: Cell = const { Cell::new(0.0) }; + // Process-wide names (font families, the custom dictionary) seen at any point of the run: + // tests running alongside add and remove them, so one may be drawn and gone by the check. + static GLOBAL_NAMES: std::cell::RefCell> = std::cell::RefCell::new(Default::default()); +} + +/// Remember the process-wide names drawn this frame could have come from. +fn note_global_names() { + GLOBAL_NAMES.with_borrow_mut(|seen| { + seen.extend(wordcraft_fonts::FontDb::global().families()); + seen.extend(wordcraft_engine::proof::user_dictionary()); + }); } fn texts(shape: &egui::Shape, out: &mut Vec) { @@ -35,10 +46,12 @@ fn frames(ctx: &egui::Context, app: &mut WordApp, steps: Vec>) TIME.set(TIME.get() + 0.1); let input = egui::RawInput { screen_rect: Some(screen), time: Some(TIME.get()), events: steps.next().unwrap_or_default(), ..Default::default() }; + note_global_names(); let mut out = ctx.run_ui(input, |ui| { app.logic(ui.ctx()); app.ui(ui); }); + note_global_names(); drawn.clear(); for s in &out.shapes { texts(&s.shape, &mut drawn); @@ -84,6 +97,9 @@ fn ribbon_controls(ctx: &egui::Context) -> Vec { fn fresh_app(ctx: &egui::Context) -> WordApp { ctx.all_styles_mut(|s| s.interaction.tooltip_delay = 0.0); let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + // Read Aloud holds each sentence instead of starting the system's speech engine: no sound on + // machines that have one, and no worker thread failing at its own pace on machines that don't. + app.session.read_aloud.backend = wordcraft_engine::speech::Backend::Hold; // Fonts install over the first frames. for _ in 0..2 { frame(ctx, &mut app); @@ -108,7 +124,9 @@ struct Exempt { impl Exempt { fn new(app: &WordApp) -> Self { - let mut names: std::collections::HashSet = wordcraft_fonts::FontDb::global().families().into_iter().collect(); + // Installed fonts, and the custom dictionary's words (File › Options › Proofing): the user's own. + note_global_names(); + let mut names: std::collections::HashSet = GLOBAL_NAMES.with_borrow(Clone::clone); names.extend(app.session.doc.styles.styles.iter().map(|s| s.name.clone())); // Fonts a document asks for, installed or not (Aptos is drawn with a substitute). names.extend([wordcraft_doc::styles::BODY_FONT, wordcraft_doc::styles::HEADING_FONT].map(str::to_string)); @@ -161,9 +179,10 @@ impl Exempt { } /// Lists untranslated interface text per UI state. `cargo test -p wordcraft-ui-egui -/// untranslated_interface_text -- --nocapture` prints the report. +/// untranslated_interface_text -- --nocapture` prints the report. It runs with the other tests: +/// input is synthetic and timed by `TIME`, Read Aloud never starts a speech thread, and +/// process-wide names other tests change are gathered every frame. #[test] -#[ignore = "failed once in ~46 runs under heavy machine load (cause not yet found); run on demand with `cargo test -p wordcraft-ui-egui untranslated_interface_text -- --ignored --nocapture`"] fn untranslated_interface_text() { pseudo::set(true); let mut found: BTreeMap> = BTreeMap::new(); @@ -352,10 +371,18 @@ fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::co | "Turabian" ) }; + // A command called with parameters it can't use (`bad parameters: \`path\` (string) is + // required`): the message names JSON parameters for scripts and MCP, so it stays English like + // the rest of the engine's errors; the status bar translates only the ones a catalog knows. + let script_error = |s: &str| s.starts_with("bad parameters: "); + // A pattern of placeholders and figures only (`{paper} {width} × {height}`) has nothing to + // 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"] { let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN); - let missing: Vec<&String> = keys.iter().filter(|s| english_words(s) && !super::has(lang, s) && !numbered(s) && !same(s)).collect(); + let missing: Vec<&String> = + keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect(); if !missing.is_empty() { report.push_str(&format!("{} strings have no {code} entry:\n{missing:#?}\n", missing.len())); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 013fdc1e7..ac3123c78 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2027,8 +2027,6 @@ AaBbCcDd АаБбВвГг All - Linear Всички – линеен формат All - Professional Всички – професионален формат - B4 (JIS) 10.12" × 14.33" B4 (JIS) 10.12" × 14.33" - B5 (JIS) 7.17" × 10.12" B5 (JIS) 7.17" × 10.12" Body text in a short paragraph to show spacing. Основен текст в кратък абзац, който показва разстоянията. Book Title Заглавие на книга CONFIDENTIAL ПОВЕРИТЕЛНО @@ -2049,9 +2047,6 @@ Emphasis Акцент Endnote Reference Препратка към бележка в края Endnote Text Текст на бележка в края - Envelope #10 4.12" × 9.50" Плик №10 4.12" × 9.50" - Envelope DL 4.33" × 8.66" Плик DL 4.33" × 8.66" - Executive 7.25" × 10.50" Executive 7.25" × 10.50" Footnote Reference Препратка към бележка под линия Footnote Text Текст на бележка под линия GB/T 7714 GB/T 7714 @@ -2073,8 +2068,6 @@ Intense Reference Изразена препратка Justification of a display equation Подравняване на уравнение на отделен ред Keywords Ключови думи - Legal 8.50" × 14.00" Legal 8.50" × 14.00" - Letter 8.50" × 11.00" Letter 8.50" × 11.00" Level 1 Ниво 1 Level 2 Ниво 2 Level 3 Ниво 3 @@ -2094,7 +2087,6 @@ SIST02 SIST02 Show the equation in the line of text Показване на уравнението в реда на текста Show the equation on a line of its own Показване на уравнението на отделен ред - Statement 5.50" × 8.50" Statement 5.50" × 8.50" Strong Удебелен Subject Тема Subtitle Подзаглавие @@ -2102,7 +2094,6 @@ Subtle Reference Лека препратка TOC Heading Заглавие на съдържание Table Grid Мрежа на таблица - Tabloid 11.00" × 17.00" Tabloid 11.00" × 17.00" Turabian Turabian Type equations in LaTeX Въвеждане на уравнения в LaTeX Type equations in the linear format (UnicodeMath) Въвеждане на уравнения в линеен формат (UnicodeMath) @@ -2474,3 +2465,25 @@ Leave out the date and time of comments and tracked changes when saving Без дата и час на коментарите и проследените промени при запазване The document you are editing keeps the names and dates; only the saved file leaves them out. Документът, който редактирате, запазва имената и датите; само записаният файл ги пропуска. Privacy Settings Текущи настройки за поверителност + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} инча + {name} {height} × {width} mm {name} {height} × {width} мм + {width} × {height} mm {width} × {height} мм + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Изследователят изисква онлайн услуга, а WordCraft пази документите ви офлайн. + These settings will be used for new documents. Тези настройки ще се използват за новите документи. + can't split at the first row таблицата не може да се раздели на първия ред + select two or more cells to merge изберете две или повече клетки за обединяване + there is no ink to replay няма мастило за възпроизвеждане + there's no citation after the caret след курсора няма цитиране + this item is already at the top level този елемент вече е на най-горното ниво + Envelope #10 Плик №10 + Envelope DL Плик DL + Reading Mode Режим за четене + Column width Ширина на колоната + Text size Размер на текста + Edit Document Редактиране на документа + Go to what: Отиване до: + Type +2 or -1 to move from the page you are on. Въведете +2 или -1, за да се преместите спрямо текущата страница. + Suggested Предложения diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a7b8f04c4..425d1a325 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2090,3 +2090,25 @@ Leave out the date and time of comments and tracked changes when saving Při ukládání vynechat datum a čas komentářů a sledovaných změn The document you are editing keeps the names and dates; only the saved file leaves them out. Upravovaný dokument si jména a data ponechá; vynechá je jen uložený soubor. Privacy Settings Aktuální nastavení soukromí + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} palce + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Výzkumník potřebuje online službu; WordCraft vaše dokumenty uchovává offline. + These settings will be used for new documents. Tato nastavení se použijí pro nové dokumenty. + can't split at the first row tabulku nelze rozdělit na prvním řádku + select two or more cells to merge vyberte ke sloučení dvě nebo více buněk + there is no ink to replay není žádný rukopis k přehrání + there's no citation after the caret za kurzorem není žádná citace + this item is already at the top level tato položka už je na nejvyšší úrovni + Envelope #10 Obálka č. 10 + Envelope DL Obálka DL + Reading Mode Režim čtení + Column width Šířka sloupce + Text size Velikost textu + Edit Document Upravit dokument + Go to what: Přejít na: + Type +2 or -1 to move from the page you are on. Zadáním +2 nebo -1 se posunete od aktuální stránky. + Suggested Navrhované diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 78c03cae1..209054cbb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2084,3 +2084,25 @@ Leave out the date and time of comments and tracked changes when saving Beim Speichern Datum und Uhrzeit von Kommentaren und nachverfolgten Änderungen weglassen The document you are editing keeps the names and dates; only the saved file leaves them out. Das bearbeitete Dokument behält Namen und Daten; nur die gespeicherte Datei lässt sie weg. Privacy Settings Aktuelle Datenschutzeinstellungen + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} Zoll + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Der Recherche-Assistent braucht einen Onlinedienst; WordCraft behält Ihre Dokumente offline. + These settings will be used for new documents. Diese Einstellungen werden für neue Dokumente verwendet. + can't split at the first row die Tabelle kann nicht an der ersten Zeile geteilt werden + select two or more cells to merge wählen Sie zum Verbinden zwei oder mehr Zellen aus + there is no ink to replay es gibt keine Freihandeingabe zum Wiedergeben + there's no citation after the caret nach der Einfügemarke steht kein Zitat + this item is already at the top level dieses Element ist bereits auf der obersten Ebene + Envelope #10 Umschlag Nr. 10 + Envelope DL Umschlag DL + Reading Mode Lesemodus + Column width Spaltenbreite + Text size Textgröße + Edit Document Dokument bearbeiten + Go to what: Gehe zu: + Type +2 or -1 to move from the page you are on. Geben Sie +2 oder -1 ein, um von der aktuellen Seite aus zu springen. + Suggested Vorschläge diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d9dbabb4c..0a694a4f0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2451,3 +2451,25 @@ preview Title Título Leave out the date and time of comments and tracked changes when saving Omitir la fecha y la hora de los comentarios y los cambios controlados al guardar The document you are editing keeps the names and dates; only the saved file leaves them out. El documento que editas conserva los nombres y las fechas; solo el archivo guardado los omite. Privacy Settings Configuración de privacidad actual + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} pulg. + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Investigador necesita un servicio en línea; WordCraft mantiene tus documentos sin conexión. + These settings will be used for new documents. Esta configuración se usará para los documentos nuevos. + can't split at the first row no se puede dividir la tabla en la primera fila + select two or more cells to merge selecciona dos o más celdas para combinarlas + there is no ink to replay no hay tinta que reproducir + there's no citation after the caret no hay ninguna cita después del cursor + this item is already at the top level este elemento ya está en el nivel superior + Envelope #10 Sobre n.º 10 + Envelope DL Sobre DL + Reading Mode Modo lectura + Column width Ancho de columna + Text size Tamaño del texto + Edit Document Editar documento + Go to what: Ir a: + Type +2 or -1 to move from the page you are on. Escribe +2 o -1 para moverte desde la página actual. + Suggested Sugerencias diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 94de44c75..8c6093c26 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2119,3 +2119,25 @@ Leave out the date and time of comments and tracked changes when saving Jäta salvestamisel välja kommentaaride ja jälitatud muudatuste kuupäev ja kellaaeg The document you are editing keeps the names and dates; only the saved file leaves them out. Redigeeritav dokument säilitab nimed ja kuupäevad; need jäetakse välja ainult salvestatud failist. Privacy Settings Praegused privaatsussätted + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} tolli + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Uurija vajab võrguteenust; WordCraft hoiab teie dokumendid võrguühenduseta. + These settings will be used for new documents. Neid sätteid kasutatakse uute dokumentide puhul. + can't split at the first row tabelit ei saa esimese rea kohalt tükeldada + select two or more cells to merge valige ühendamiseks kaks või rohkem lahtrit + there is no ink to replay esitamiseks pole käsikirja + there's no citation after the caret kursori järel pole viidet + this item is already at the top level see üksus on juba ülemisel tasemel + Envelope #10 Ümbrik nr 10 + Envelope DL Ümbrik DL + Reading Mode Lugemisrežiim + Column width Veeru laius + Text size Teksti suurus + Edit Document Redigeeri dokumenti + Go to what: Mine: + Type +2 or -1 to move from the page you are on. Sisesta +2 või -1, et liikuda praegusest leheküljest edasi või tagasi. + Suggested Soovitused diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c459e24de..b774caa55 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2093,3 +2093,25 @@ Leave out the date and time of comments and tracked changes when saving Omettre la date et l’heure des commentaires et des modifications suivies à l’enregistrement The document you are editing keeps the names and dates; only the saved file leaves them out. Le document en cours de modification garde les noms et les dates ; seul le fichier enregistré les omet. Privacy Settings Paramètres de confidentialité actuels + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} po + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Chercheur nécessite un service en ligne ; WordCraft garde vos documents hors ligne. + These settings will be used for new documents. Ces paramètres seront utilisés pour les nouveaux documents. + can't split at the first row impossible de fractionner le tableau à la première ligne + select two or more cells to merge sélectionnez au moins deux cellules à fusionner + there is no ink to replay il n’y a pas d’encre à rejouer + there's no citation after the caret il n’y a pas de citation après le curseur + this item is already at the top level cet élément est déjà au niveau supérieur + Envelope #10 Enveloppe n° 10 + Envelope DL Enveloppe DL + Reading Mode Mode Lecture + Column width Largeur de colonne + Text size Taille du texte + Edit Document Modifier le document + Go to what: Atteindre : + Type +2 or -1 to move from the page you are on. Tapez +2 ou -1 pour vous déplacer à partir de la page actuelle. + Suggested Suggestions diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 03baf13f3..934b9ee3b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1148,15 +1148,6 @@ Gallery Galéria Sketchbook Vázlatfüzet Studio Stúdió - Letter 8.50" × 11.00" Letter 8,50" × 11,00" - Legal 8.50" × 14.00" Legal 8,50" × 14,00" - Executive 7.25" × 10.50" Executive 7,25" × 10,50" - Statement 5.50" × 8.50" Statement 5,50" × 8,50" - Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" - B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" - B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" - Envelope #10 4.12" × 9.50" Boríték #10 4,12" × 9,50" - Envelope DL 4.33" × 8.66" Boríték DL 4,33" × 8,66" Plain Table 1 Egyszerű táblázat 1 Grid Table 1 Light Rácsos táblázat 1, világos Grid Table 4 – Blue Rácsos táblázat 4 – kék @@ -2483,3 +2474,25 @@ Leave out the date and time of comments and tracked changes when saving Mentéskor a megjegyzések és a követett módosítások dátumának és idejének elhagyása The document you are editing keeps the names and dates; only the saved file leaves them out. A szerkesztett dokumentum megtartja a neveket és a dátumokat; csak a mentett fájlból maradnak ki. Privacy Settings Jelenlegi adatvédelmi beállítások + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} hüvelyk + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. A Kutatássegédhez online szolgáltatás kell; a WordCraft a dokumentumait offline tartja. + These settings will be used for new documents. Ezek a beállítások az új dokumentumokra lesznek érvényesek. + can't split at the first row a táblázat nem osztható fel az első sornál + select two or more cells to merge jelöljön ki legalább két cellát az egyesítéshez + there is no ink to replay nincs visszajátszható tinta + there's no citation after the caret a kurzor után nincs forráshivatkozás + this item is already at the top level ez az elem már a legfelső szinten van + Envelope #10 Boríték #10 + Envelope DL Boríték DL + Reading Mode Olvasó mód + Column width Oszlopszélesség + Text size Szövegméret + Edit Document Dokumentum szerkesztése + Go to what: Ugrás ide: + Type +2 or -1 to move from the page you are on. Írd be: +2 vagy -1, ha az aktuális oldalhoz képest szeretnél lépni. + Suggested Javaslatok diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 70ae8035b..6c12a47ba 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2113,8 +2113,6 @@ Annotation Reference Rimando commento Annotation Subject Oggetto commento Annotation Text Testo commento - B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" - B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" Baseline Dots Puntini sulla linea di base Basic Math Matematica di base Binomial Coefficient Coefficiente binomiale @@ -2199,13 +2197,10 @@ Empty Matrices Matrici vuote Endnote Reference Rimando nota di chiusura Endnote Text Testo nota di chiusura - Envelope #10 4.12" × 9.50" Busta n. 10 4,12" × 9,50" Envelope Address Indirizzo busta - Envelope DL 4.33" × 8.66" Busta DL 4,33" × 8,66" Envelope Return Mittente busta Equal Equal Uguale uguale Equal by Definition Uguale per definizione - Executive 7.25" × 10.50" Executive 7,25" × 10,50" Exponential Esponenziale File name: Nome file: Floor Parte intera inferiore @@ -2269,8 +2264,6 @@ Left Subscript-Superscript Pedice e apice a sinistra Leftwards Arrow Above Freccia verso sinistra sopra Leftwards Harpoon Above Arpione verso sinistra sopra - Legal 8.50" × 14.00" Legale 8,50" × 14,00" - Letter 8.50" × 11.00" Lettera 8,50" × 11,00" Letter-Like Symbols Simboli simili a lettere Limit Limite Limit Example Esempio di limite @@ -2366,7 +2359,6 @@ Stack Object (Three) Oggetto pila (tre) Stacked Fraction Frazione in colonna Stacked, no bar In colonna, senza linea - Statement 5.50" × 8.50" Statement 5,50" × 8,50" Strong Forte Subscript-Superscript Pedice-apice Subscripts and Superscripts Pedici e apici @@ -2389,7 +2381,6 @@ TOC {n} Sommario {n} Table Grid Griglia tabella Table of Figures Indice delle figure - Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" Tangent Function Funzione tangente Tangent formula Formula della tangente Text over Colon Equal Testo sopra due punti uguale @@ -2469,3 +2460,25 @@ preview Title Esempio Leave out the date and time of comments and tracked changes when saving Ometti data e ora di commenti e revisioni al salvataggio The document you are editing keeps the names and dates; only the saved file leaves them out. Il documento che stai modificando conserva nomi e date; solo il file salvato li omette. Privacy Settings Impostazioni di privacy attuali + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} pollici + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Ricercatore richiede un servizio online; WordCraft mantiene i tuoi documenti offline. + These settings will be used for new documents. Queste impostazioni verranno usate per i nuovi documenti. + can't split at the first row impossibile dividere la tabella alla prima riga + select two or more cells to merge seleziona due o più celle da unire + there is no ink to replay non c’è input penna da riprodurre + there's no citation after the caret non c’è nessuna citazione dopo il cursore + this item is already at the top level questo elemento è già al livello superiore + Envelope #10 Busta n. 10 + Envelope DL Busta DL + Reading Mode Modalità di lettura + Column width Larghezza colonna + Text size Dimensioni testo + Edit Document Modifica documento + Go to what: Vai a: + Type +2 or -1 to move from the page you are on. Digita +2 o -1 per spostarti dalla pagina corrente. + Suggested Suggerimenti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0a82ebf86..767b2d7c1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2085,3 +2085,25 @@ Leave out the date and time of comments and tracked changes when saving 保存時にコメントと変更履歴の日時を含めない The document you are editing keeps the names and dates; only the saved file leaves them out. 編集中のドキュメントには名前と日時が残ります。保存したファイルにだけ含まれません。 Privacy Settings 現在のプライバシー設定 + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} インチ + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}、{columns} × {rows}、{size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. リサーチツールにはオンライン サービスが必要です。WordCraft では文書はオフラインのままです。 + These settings will be used for new documents. これらの設定は新しい文書に使用されます。 + can't split at the first row 先頭の行では表を分割できません + select two or more cells to merge 結合するセルを 2 つ以上選択してください + there is no ink to replay 再生するインクがありません + there's no citation after the caret カーソルの後に引用文献がありません + this item is already at the top level この項目は既に最上位レベルです + Envelope #10 封筒 #10 + Envelope DL 封筒 DL + Reading Mode 閲覧モード + Column width 列の幅 + Text size 文字サイズ + Edit Document 文書の編集 + Go to what: 移動先: + Type +2 or -1 to move from the page you are on. 現在のページから移動するには +2 や -1 のように入力します。 + Suggested おすすめ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fa46d7918..3551b816d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2155,15 +2155,6 @@ preview Title Tytuł DRAFT WERSJA ROBOCZA SAMPLE WZÓR URGENT PILNE - Letter 8.50" × 11.00" Letter 8,50" × 11,00" - Legal 8.50" × 14.00" Legal 8,50" × 14,00" - Executive 7.25" × 10.50" Executive 7,25" × 10,50" - Statement 5.50" × 8.50" Statement 5,50" × 8,50" - Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" - B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" - B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" - Envelope #10 4.12" × 9.50" Koperta nr 10 4,12" × 9,50" - Envelope DL 4.33" × 8.66" Koperta DL 4,33" × 8,66" Plain Table 1 Prosta tabela 1 Grid Table 1 Light Tabela z siatką 1 — jasna Grid Table 4 – Blue Tabela z siatką 4 — niebieska @@ -2478,3 +2469,25 @@ preview Title Tytuł Leave out the date and time of comments and tracked changes when saving Pomiń datę i godzinę komentarzy i śledzonych zmian przy zapisywaniu The document you are editing keeps the names and dates; only the saved file leaves them out. Edytowany dokument zachowuje nazwiska i daty; pomija je tylko zapisany plik. Privacy Settings Bieżące ustawienia prywatności + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} cala + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Badacz wymaga usługi online, a WordCraft przechowuje Twoje dokumenty offline. + These settings will be used for new documents. Te ustawienia będą używane w nowych dokumentach. + can't split at the first row nie można podzielić tabeli na pierwszym wierszu + select two or more cells to merge zaznacz co najmniej dwie komórki do scalenia + there is no ink to replay brak pisma odręcznego do odtworzenia + there's no citation after the caret za kursorem nie ma cytatu + this item is already at the top level ten element jest już na najwyższym poziomie + Envelope #10 Koperta nr 10 + Envelope DL Koperta DL + Reading Mode Tryb czytania + Column width Szerokość kolumny + Text size Rozmiar tekstu + Edit Document Edytuj dokument + Go to what: Przejdź do: + Type +2 or -1 to move from the page you are on. Wpisz +2 lub -1, aby przejść o tyle stron od bieżącej. + Suggested Sugerowane diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 83f870b54..9d65ad9cb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2097,3 +2097,25 @@ Leave out the date and time of comments and tracked changes when saving Omitir a data e a hora dos comentários e das alterações controladas ao salvar The document you are editing keeps the names and dates; only the saved file leaves them out. O documento que você está editando mantém os nomes e as datas; só o arquivo salvo os omite. Privacy Settings Configurações de privacidade atuais + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} pol. + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. O Pesquisador precisa de um serviço online; o WordCraft mantém seus documentos offline. + These settings will be used for new documents. Essas configurações serão usadas nos novos documentos. + can't split at the first row não é possível dividir a tabela na primeira linha + select two or more cells to merge selecione duas ou mais células para mesclar + there is no ink to replay não há tinta para reproduzir + there's no citation after the caret não há citação depois do cursor + this item is already at the top level este item já está no nível superior + Envelope #10 Envelope nº 10 + Envelope DL Envelope DL + Reading Mode Modo de leitura + Column width Largura da coluna + Text size Tamanho do texto + Edit Document Editar documento + Go to what: Ir para: + Type +2 or -1 to move from the page you are on. Digite +2 ou -1 para se mover a partir da página atual. + Suggested Sugestões diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6b20dcbb3..14307f562 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1151,15 +1151,6 @@ preview Title Пример Gallery Галерея Sketchbook Скетчбук Studio Студия - Letter 8.50" × 11.00" Letter 8,50" × 11,00" - Legal 8.50" × 14.00" Legal 8,50" × 14,00" - Executive 7.25" × 10.50" Executive 7,25" × 10,50" - Statement 5.50" × 8.50" Statement 5,50" × 8,50" - Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" - B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" - B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" - Envelope #10 4.12" × 9.50" Конверт №10 4,12" × 9,50" - Envelope DL 4.33" × 8.66" Конверт DL 4,33" × 8,66" Plain Table 1 Простая таблица 1 Grid Table 1 Light Таблица-сетка 1, светлая Grid Table 4 – Blue Таблица-сетка 4 — синяя @@ -2484,3 +2475,25 @@ preview Title Пример Leave out the date and time of comments and tracked changes when saving Не сохранять дату и время примечаний и отслеживаемых изменений The document you are editing keeps the names and dates; only the saved file leaves them out. Редактируемый документ сохраняет имена и даты; их не будет только в сохранённом файле. Privacy Settings Текущие параметры конфиденциальности + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} дюйма + {name} {height} × {width} mm {name} {height} × {width} мм + {width} × {height} mm {width} × {height} мм + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Исследователю нужен онлайн-сервис, а WordCraft работает с документами без подключения к интернету. + These settings will be used for new documents. Эти параметры будут использоваться для новых документов. + can't split at the first row нельзя разделить таблицу по первой строке + select two or more cells to merge выделите две или более ячеек для объединения + there is no ink to replay нет рукописного ввода для воспроизведения + there's no citation after the caret после курсора нет ссылки на источник + this item is already at the top level этот элемент уже на верхнем уровне + Envelope #10 Конверт №10 + Envelope DL Конверт DL + Reading Mode Режим чтения + Column width Ширина столбца + Text size Размер текста + Edit Document Редактировать документ + Go to what: Перейти к: + Type +2 or -1 to move from the page you are on. Введите +2 или -1, чтобы перейти на столько страниц от текущей. + Suggested Предлагаемые diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8dd5be177..da721fbd4 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2095,3 +2095,25 @@ Leave out the date and time of comments and tracked changes when saving Izostavi datum i vreme komentara i praćenih izmena pri čuvanju The document you are editing keeps the names and dates; only the saved file leaves them out. Dokument koji uređujete zadržava imena i datume; izostavlja ih samo sačuvana datoteka. Privacy Settings Trenutna podešavanja privatnosti + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} inča + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Istraživaču je potrebna usluga na mreži, a WordCraft vaše dokumente čuva van mreže. + These settings will be used for new documents. Ova podešavanja će se koristiti za nove dokumente. + can't split at the first row tabela ne može da se podeli kod prvog reda + select two or more cells to merge izaberite dve ili više ćelija za spajanje + there is no ink to replay nema mastila za reprizu + there's no citation after the caret posle kursora nema citata + this item is already at the top level ova stavka je već na najvišem nivou + Envelope #10 Koverta br. 10 + Envelope DL Koverta DL + Reading Mode Režim čitanja + Column width Širina kolone + Text size Veličina teksta + Edit Document Uredi dokument + Go to what: Idi na: + Type +2 or -1 to move from the page you are on. Unesite +2 ili -1 da biste se pomerili od strane na kojoj ste. + Suggested Predlozi diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 3c35a3166..7441b294a 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2099,3 +2099,25 @@ Leave out the date and time of comments and tracked changes when saving Изостави датум и време коментара и праћених измена при чувању The document you are editing keeps the names and dates; only the saved file leaves them out. Документ који уређујете задржава имена и датуме; изоставља их само сачувана датотека. Privacy Settings Тренутна подешавања приватности + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} инча + {name} {height} × {width} mm {name} {height} × {width} мм + {width} × {height} mm {width} × {height} мм + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Истраживачу је потребна услуга на мрежи, а WordCraft ваше документе чува ван мреже. + These settings will be used for new documents. Ова подешавања ће се користити за нове документе. + can't split at the first row табела не може да се подели код првог реда + select two or more cells to merge изаберите две или више ћелија за спајање + there is no ink to replay нема мастила за репризу + there's no citation after the caret после курсора нема цитата + this item is already at the top level ова ставка је већ на највишем нивоу + Envelope #10 Коверта бр. 10 + Envelope DL Коверта DL + Reading Mode Режим читања + Column width Ширина колоне + Text size Величина текста + Edit Document Уреди документ + Go to what: Иди на: + Type +2 or -1 to move from the page you are on. Унесите +2 или -1 да бисте се померили од стране на којој сте. + Suggested Предлози diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 32d528ed1..7300dfecf 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1150,15 +1150,6 @@ preview Title Örnek Gallery Galeri Sketchbook Eskiz Defteri Studio Stüdyo - Letter 8.50" × 11.00" Letter 8,50" × 11,00" - Legal 8.50" × 14.00" Legal 8,50" × 14,00" - Executive 7.25" × 10.50" Executive 7,25" × 10,50" - Statement 5.50" × 8.50" Statement 5,50" × 8,50" - Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" - B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" - B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" - Envelope #10 4.12" × 9.50" Zarf No. 10 4,12" × 9,50" - Envelope DL 4.33" × 8.66" Zarf DL 4,33" × 8,66" Plain Table 1 Düz Tablo 1 Grid Table 1 Light Kılavuz Tablo 1 Açık Grid Table 4 – Blue Kılavuz Tablo 4 – Mavi @@ -2483,3 +2474,25 @@ preview Title Örnek Leave out the date and time of comments and tracked changes when saving Kaydederken açıklamaların ve izlenen değişikliklerin tarih ve saatini dahil etme The document you are editing keeps the names and dates; only the saved file leaves them out. Düzenlediğiniz belge adları ve tarihleri korur; yalnızca kaydedilen dosya bunları içermez. Privacy Settings Geçerli gizlilik ayarları + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} inç + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Araştırmacı çevrimiçi bir hizmet gerektirir; WordCraft belgelerinizi çevrimdışı tutar. + These settings will be used for new documents. Bu ayarlar yeni belgeler için kullanılacak. + can't split at the first row tablo ilk satırdan bölünemez + select two or more cells to merge birleştirmek için iki veya daha fazla hücre seçin + there is no ink to replay yeniden oynatılacak mürekkep yok + there's no citation after the caret imlecin sonrasında atıf yok + this item is already at the top level bu öğe zaten en üst düzeyde + Envelope #10 Zarf No. 10 + Envelope DL Zarf DL + Reading Mode Okuma Modu + Column width Sütun genişliği + Text size Metin boyutu + Edit Document Belgeyi Düzenle + Go to what: Gidilecek yer: + Type +2 or -1 to move from the page you are on. Bulunduğunuz sayfadan ilerlemek için +2, geri gitmek için -1 yazın. + Suggested Önerilenler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index aa1ba4d49..cb25ef54b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2097,3 +2097,25 @@ Leave out the date and time of comments and tracked changes when saving Не зберігати дату й час приміток і відстежуваних змін The document you are editing keeps the names and dates; only the saved file leaves them out. Документ, який ви редагуєте, зберігає імена й дати; їх не буде лише у збереженому файлі. Privacy Settings Поточні налаштування конфіденційності + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} дюйма + {name} {height} × {width} mm {name} {height} × {width} мм + {width} × {height} mm {width} × {height} мм + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. Досліднику потрібна онлайн-служба, а WordCraft працює з документами без підключення до інтернету. + These settings will be used for new documents. Ці параметри використовуватимуться для нових документів. + can't split at the first row не можна розділити таблицю на першому рядку + select two or more cells to merge виділіть дві або більше клітинок, щоб об'єднати їх + there is no ink to replay немає рукописних приміток для відтворення + there's no citation after the caret після курсора немає цитати + this item is already at the top level цей елемент уже на верхньому рівні + Envelope #10 Конверт №10 + Envelope DL Конверт DL + Reading Mode Режим читання + Column width Ширина стовпця + Text size Розмір тексту + Edit Document Редагувати документ + Go to what: Перейти до: + Type +2 or -1 to move from the page you are on. Введіть +2 або -1, щоб перейти на стільки сторінок від поточної. + Suggested Пропоновані diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9eab71385..2fa628a14 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2097,3 +2097,25 @@ Leave out the date and time of comments and tracked changes when saving 保存时不包含批注和修订的日期与时间 The document you are editing keeps the names and dates; only the saved file leaves them out. 正在编辑的文档保留姓名和日期;只有保存的文件中不包含它们。 Privacy Settings 当前隐私设置 + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} 英寸 + {name} {height} × {width} mm {name} {height} × {width} 毫米 + {width} × {height} mm {width} × {height} 毫米 + {kind}, {columns} × {rows}, {size} ({paper}) {kind},{columns} × {rows},{size}({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. 研究工具需要在线服务;WordCraft 会将你的文档保留在本地。 + These settings will be used for new documents. 这些设置将用于新文档。 + can't split at the first row 无法在第一行处拆分表格 + select two or more cells to merge 请选择两个或更多单元格进行合并 + there is no ink to replay 没有可重播的墨迹 + there's no citation after the caret 插入点后没有引文 + this item is already at the top level 此项已位于最高级别 + Envelope #10 10 号信封 + Envelope DL DL 信封 + Reading Mode 阅读模式 + Column width 栏宽 + Text size 文字大小 + Edit Document 编辑文档 + Go to what: 定位目标: + Type +2 or -1 to move from the page you are on. 输入 +2 或 -1 可从当前页向后或向前移动。 + Suggested 建议 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0b968cc77..b1284fd27 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2086,3 +2086,25 @@ Leave out the date and time of comments and tracked changes when saving 儲存時不包含註解和追蹤修訂的日期與時間 The document you are editing keeps the names and dates; only the saved file leaves them out. 正在編輯的文件會保留名稱和日期;只有儲存的檔案不包含它們。 Privacy Settings 目前的隱私設定 + +# #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To + {name} {height} × {width} in {name} {height} × {width} 英寸 + {name} {height} × {width} mm {name} {height} × {width} 公釐 + {width} × {height} mm {width} × {height} 公釐 + {kind}, {columns} × {rows}, {size} ({paper}) {kind},{columns} × {rows},{size}({paper}) + Researcher needs an online service; WordCraft keeps your documents offline. 研究工具需要連線;WordCraft 會將您的文件保留在本機。 + These settings will be used for new documents. 這些設定將用於新文件。 + can't split at the first row 無法在第一列處分割表格 + select two or more cells to merge 請選取兩個以上的儲存格進行合併 + there is no ink to replay 沒有可重播的筆跡 + there's no citation after the caret 插入點之後沒有引文 + this item is already at the top level 此項目已位於最高層級 + Envelope #10 10 號信封 + Envelope DL DL 信封 + Reading Mode 閱讀模式 + Column width 欄寬 + Text size 文字大小 + Edit Document 編輯文件 + Go to what: 移至目標: + Type +2 or -1 to move from the page you are on. 輸入 +2 或 -1 可從目前頁面向後或向前移動。 + Suggested 建議 diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs index 89d953604..2b05ebe1b 100644 --- a/crates/ui-egui/src/read_aloud.rs +++ b/crates/ui-egui/src/read_aloud.rs @@ -128,8 +128,10 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { { run = Some(("readAloud.skipCitations", json!({"value": skip}))); } - if let Some(e) = app.session.read_aloud.error() { - ui.label(egui::RichText::new(e).color(t.red).small()); + // The error `poll` saw this frame, not the worker's latest: the player thread can set + // one between the two, and the window would show a message the status bar hasn't. + if let Some(e) = app.read_aloud_error.as_ref().filter(|e| app.session.read_aloud.error().as_ref() == Some(*e)) { + ui.label(egui::RichText::new(e.as_str()).color(t.red).small()); } }); if !open { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index b52d210b2..8f8e76307 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1158,8 +1158,13 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { }); menu_button(ui, app, "size", Some("Size"), "Size", true, "layout.size", |ui, app| { for (n, w, h) in wordcraft_geom::PAPER_SIZES { - // In the measurement unit, written the region's way (`21 cm × 29,7 cm`, #838). - mi(ui, app, &format!("{n} {} × {}", crate::options::length(*w), crate::options::length(*h)), "layout.size", json!({"name": n})); + // Paper names are the industry's (Letter, A4, B5 (JIS)) and stay as they are; the + // envelopes say what they are. Sizes in the measurement unit, written the region's + // way (`21 cm × 29,7 cm`, #838). + let name = if n.starts_with("Envelope ") { tl!(n) } else { n }; + let (width, height) = (crate::options::length(*w), crate::options::length(*h)); + let label = crate::i18n::fmt(tl!("{paper} {width} × {height}"), &[("paper", name), ("width", &width), ("height", &height)]); + mi_text(ui, app, &label, "layout.size", json!({"name": n})); } }); menu_button(ui, app, "columns", Some("Columns"), "Columns", true, "layout.columns", |ui, app| { diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 7b7f654bf..4fec80d7b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -77,6 +77,15 @@ the Russian catalog, built-in style names shown translated, plus the citation st Polish is the only one besides English with proofing tools, and a Polish interface writes new documents in Polish (`pl-PL`). +**Interface audit (runs in CI).** `untranslated_interface_text` lays the app out in a pseudo-language +(every tab, File page, dialog, pane, the right-click menu, and a hover and a click on every ribbon +control) and fails on visible text that skipped the catalog; then Russian, Hungarian, Turkish and +Bulgarian must have every string it looked up. Proper names (citation styles such as APA or +GOST, LaTeX, Unicode) and `bad parameters:` errors, which name a command's JSON parameters for +scripts and MCP, stay English. Size labels (Envelopes, Labels, Layout › Size) are translated +patterns (`{name} {height} × {width} in`) with the region's decimal separator; paper names +other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). + ## Proofing per language | Language | Spelling | Suggestions | Grammar | Hyphenation | @@ -160,6 +169,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Italian proofing: it-IT spelling from the installed Hunspell dictionary (system `it_IT`, never bundled — upstream is GPL), accent-first suggestions, conservative grammar with Italian messages, TeX Italian hyphenation patterns (MIT option); Review › Language already offered Italian; an `it` interface writes it-IT documents | +| 2026-10-13 | minor | Interface audit back in CI (#1128): Envelopes, Labels and Layout › Size labels go through translated patterns with the region's decimal separator (the old fixed-format paper rows, dead since #838, removed); 20 strings added to all 18 catalogs (status-bar messages, Reading Mode toolbar, Go To, Search's Suggested); citation style names and `bad parameters:` errors exempt; the Read Aloud player no longer draws a speech error before the frame has polled it, the audit's rare failure under load | | 2026-10-11 | minor | Turkish interface catalog completed (2,325 entries, the same keys as Russian): equation gallery, built-in style names, watermarks, paper sizes and preview tiles; the interface audit checks Turkish too (#774) | | 2026-10-12 | trivial | Region conventions: File › Options › Advanced › Region (saved) and an Automatic units entry; Unix `LC_PAPER`/`LC_MEASUREMENT`/`LC_NUMERIC` each decide their own convention; the web reads the browser's locale; `WORDCRAFT_NO_PREFS` runs stay en-US; where the decimal separator comes from (#838 review) | | 2026-10-12 | minor | New blank documents follow the system region: A4 outside the Letter territories, centimetres outside the US, the region's decimal separator in length fields; explicit Measurement units choice wins (#838) | From 44086d08c8f70793529eeccad7c4bdb9179d0f65 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 18:49:37 +0200 Subject: [PATCH 037/199] Double-click the margin to create and edit a header or footer (#960) (#1144) * #960: double-click the margin to create and edit a header or footer Double-clicking the top or bottom margin in Print Layout now edits the header/footer that page shows, creating it (first page, even page or default slot) when the page has none. insert.editHeader/editFooter take {"page"}; insert.closeHeader takes away a created one left empty. Co-Authored-By: Claude Opus 5.5 * #960: making a header by double-click is an undoable, protected edit insert.editHeader/editFooter stay pure and hand creation to the new mutating insert.createHeader/createFooter; closeHeader removes empty new ones through insert.removeEmptyHeaders. An empty new header is never saved, one an undo brings back goes on the next close, and a new header goes in the first section (linked to later ones, as in Word). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/cmd/insert.rs | 291 +++++++++++++++++++++++++++++++- crates/engine/src/session.rs | 8 + crates/layout/src/hit.rs | 15 +- crates/layout/src/lib.rs | 22 +++ crates/ui-egui/src/canvas.rs | 93 +++++++++- docs/ui-parity.md | 4 +- 8 files changed, 424 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0b934414e..9b140cd3e 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-13 · **Change:** trivial (progress log: interface audit back in CI, #1128; 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-13 · **Change:** trivial (progress log: interface audit back in CI, #1128; previously trivial: progress log: double-click the margin for a new header or footer, #960; 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. @@ -148,6 +148,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | +| 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -296,6 +297,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | +| 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 42ad9a7b2..86d58d17f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -245,7 +245,9 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { } s.stamp_save(); let password = s.password.as_ref().map(crate::io::Password::as_str); - crate::io::save_path_with(&path, &s.doc, password, &opts).map_err(CmdError::Failed)?; + // A header/footer just made and left empty isn't saved (#960). + let copy = super::insert::saved_copy(s); + crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts).map_err(CmdError::Failed)?; let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy()); if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) { s.path = Some(path.clone()); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 1bc36b049..a0982ebda 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -47,13 +47,35 @@ pub fn specs() -> Vec { CommandSpec::new("insert.header", "Header", "Insert › Header & Footer", |s, v| header_footer(s, v, true)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|title"}"#), CommandSpec::new("insert.footer", "Footer", "Insert › Header & Footer", |s, v| header_footer(s, v, false)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|pageNumber"}"#), CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y"}"#), - CommandSpec::new("insert.editHeader", "Edit Header", "Insert › Header & Footer › Header", |s, _| edit_hf(s, true)).pure(), - CommandSpec::new("insert.editFooter", "Edit Footer", "Insert › Header & Footer › Footer", |s, _| edit_hf(s, false)).pure(), + // Pure: going into an existing header/footer changes nothing; one the page doesn't have yet + // is made by the mutating `insert.createHeader`/`insert.createFooter` (undoable, refused in + // a protected document). + CommandSpec::new("insert.editHeader", "Edit Header", "Insert › Header & Footer › Header", |s, v| edit_hf(s, v, true)) + .params(r#"{"page"?: number (0-based: the header that page shows, created if it has none)}"#) + .pure(), + CommandSpec::new("insert.editFooter", "Edit Footer", "Insert › Header & Footer › Footer", |s, v| edit_hf(s, v, false)) + .params(r#"{"page"?: number (0-based: the footer that page shows, created if it has none)}"#) + .pure(), + CommandSpec::new("insert.createHeader", "Header", "Insert › Header & Footer", |s, v| create_hf(s, v, true)) + .params(r#"{"page"?: number (0-based; default: the caret's section)} — the header that page shows, created if it has none, with the caret in it"#), + CommandSpec::new("insert.createFooter", "Footer", "Insert › Header & Footer", |s, v| create_hf(s, v, false)) + .params(r#"{"page"?: number (0-based; default: the caret's section)} — the footer that page shows, created if it has none, with the caret in it"#), CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| { + if !new_empty_headers(s).is_empty() { + // Its own undo step; a protected document keeps it, and the caret still leaves. + let _ = s.run("insert.removeEmptyHeaders", &json!({})); + } s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); sel_result(s) }) .pure(), + CommandSpec::new("insert.removeEmptyHeaders", "Close Header and Footer", "Header & Footer", |s, _| { + let ids = new_empty_headers(s); + if strip_headers(&mut s.doc, &ids) { + s.touch(); + } + sel_result(s) + }), CommandSpec::new("insert.removeHeader", "Remove Header", "Insert › Header & Footer › Header", |s, _| { s.doc.last_section.headers = Default::default(); for path in s.doc.para_paths(StoryRef::Body) { @@ -445,13 +467,149 @@ fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult { Ok(json!({"story": id})) } -fn edit_hf(s: &mut Session, header: bool) -> CmdResult { - let id = hf_part(s, header); +fn edit_hf(s: &mut Session, v: &Value, header: bool) -> CmdResult { + let page = p::u64(v, "page").map(|page| usize::try_from(page).unwrap_or(usize::MAX)); + let shown = match page { + Some(page) => shown_hf(s, page, header)?, + None => { + let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; + let set = s + .doc + .sections() + .into_iter() + .find(|(end, _)| block <= *end) + .or_else(|| s.doc.sections().pop()) + .map(|(_, sp)| if header { sp.headers.default } else { sp.footers.default }); + set.flatten().filter(|id| s.doc.parts.contains_key(id)) + } + }; + let Some(id) = shown else { + // None yet: made by the mutating command, so it is an undo step and protection applies. + let cmd = if header { "insert.createHeader" } else { "insert.createFooter" }; + let params = page.map_or_else(|| json!({}), |page| json!({ "page": page })); + return s.run(cmd, ¶ms); + }; + if let Some(page) = page { + s.page_hint = page; + } s.touch(); s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id))); Ok(json!({"story": id})) } +/// The header (or footer) page `page` shows, if any; an error for a page that isn't there. +fn shown_hf(s: &mut Session, page: usize, header: bool) -> Result, CmdError> { + let layout = s.layout(); + let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?; + let shown = if header { pg.header_story } else { pg.footer_story }; + Ok(shown.filter(|id| s.doc.parts.contains_key(id))) +} + +fn create_hf(s: &mut Session, v: &Value, header: bool) -> CmdResult { + let id = match p::u64(v, "page") { + Some(page) => { + let page = usize::try_from(page).unwrap_or(usize::MAX); + s.page_hint = page; + page_hf_part(s, page, header)? + } + None => { + let before = s.doc.parts.len(); + let id = hf_part(s, header); + if s.doc.parts.len() > before { + s.new_header_parts.push(id); + } + id + } + }; + s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id))); + Ok(json!({"story": id})) +} + +/// The header (or footer) page `page` shows; when it shows none, a new empty one in the slot the +/// page uses (first page, even page or the default), as Word creates on a double-click in the +/// empty header area (#960). It goes in the first section: sections link to the previous one by +/// default, so as in Word it shows on every page that uses that slot and has none of its own. +fn page_hf_part(s: &mut Session, page: usize, header: bool) -> Result { + if let Some(id) = shown_hf(s, page, header)? { + return Ok(id); + } + let layout = s.layout(); + let slot = layout.pages.get(page).map(|pg| pg.hf_slot).unwrap_or_default(); + // The page shows none, so no section up to its own has this slot: the first section's is + // inherited all the way to it. + let block = s.doc.sections().first().map(|(end, _)| *end).unwrap_or(0); + let style = if header { "Header" } else { "Footer" }; + let id = s.doc.add_part(if header { PartKind::Header } else { PartKind::Footer }, vec![para_block(Paragraph::new().styled(style))]); + let sect = s.doc.section_mut(block); + let set = if header { &mut sect.headers } else { &mut sect.footers }; + match slot { + wordcraft_layout::HfSlot::First => set.first = Some(id), + wordcraft_layout::HfSlot::Even => set.even = Some(id), + wordcraft_layout::HfSlot::Default => set.default = Some(id), + } + s.new_header_parts.push(id); + Ok(id) +} + +/// The headers/footers made by `insert.createHeader`/`insert.createFooter` that are still empty +/// (one empty paragraph). The list keeps them after they are taken away, so one an undo brings +/// back is taken away again on the next close. +fn new_empty_headers(s: &Session) -> Vec { + s.new_header_parts + .iter() + .copied() + .filter(|id| { + s.doc.parts.get(id).is_some_and(|part| { + matches!(part.kind, PartKind::Header | PartKind::Footer) + && part.blocks.len() == 1 + && part.blocks.first().is_some_and(|b| matches!(&**b, Block::Para(p) if p.text.is_empty() && p.objects.is_empty())) + }) + }) + .collect() +} + +/// The document as saved: without the headers/footers just made and still empty, so an unwritten +/// one is never saved (`None` when there are none, to save the document as it is). +pub(crate) fn saved_copy(s: &Session) -> Option { + let ids = new_empty_headers(s); + if ids.is_empty() { + return None; + } + let mut doc = s.doc.clone(); + strip_headers(&mut doc, &ids).then_some(doc) +} + +/// Takes headers/footers `ids` out of the document and every section that refers to them; +/// whether anything was taken away. +fn strip_headers(doc: &mut wordcraft_doc::Document, ids: &[u32]) -> bool { + if ids.is_empty() { + return false; + } + let clear = |set: &mut wordcraft_doc::section::HeaderSet| { + for slot in [&mut set.default, &mut set.first, &mut set.even] { + if slot.is_some_and(|id| ids.contains(&id)) { + *slot = None; + } + } + }; + clear(&mut doc.last_section.headers); + clear(&mut doc.last_section.footers); + for path in doc.para_paths(StoryRef::Body) { + if doc.para(StoryRef::Body, &path).is_some_and(|p| p.section.is_some()) + && let Ok(p) = doc.para_mut(StoryRef::Body, &path) + && let Some(sec) = p.section.as_mut() + { + clear(&mut sec.headers); + clear(&mut sec.footers); + } + } + let mut any = false; + for id in ids { + any |= doc.parts.remove(id).is_some(); + } + any +} + fn page_number(s: &mut Session, v: &Value) -> CmdResult { let position = p::str(v, "position").unwrap_or("bottom"); let align = match p::str(v, "align").unwrap_or("center") { @@ -649,6 +807,131 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult { mod tests { use super::*; + /// #960: editing the header of a page that has none creates the one that page shows (first + /// page, even page or default); typing goes into it, and closing an unwritten one takes it + /// away again so the saved file stays as it was. + #[test] + fn edit_header_on_a_page_creates_its_slot() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "One"})).unwrap(); + s.run("insert.pageBreak", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Two"})).unwrap(); + assert_eq!(s.layout().pages.len(), 2); + // Plain document: the default header, then closing it unwritten leaves no header. + let r = s.run("insert.editHeader", &json!({"page": 1})).unwrap(); + let id = r["story"].as_u64().unwrap() as u32; + assert_eq!(s.sel.focus.story, StoryRef::Part(id)); + assert_eq!(s.doc.last_section.headers.default, Some(id)); + s.run("insert.closeHeader", &json!({})).unwrap(); + assert_eq!(s.doc.last_section.headers, Default::default()); + assert!(!s.doc.parts.contains_key(&id)); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert!(back.parts.values().all(|p| !matches!(p.kind, PartKind::Header | PartKind::Footer)), "no empty header saved"); + // Written footer stays. + let r = s.run("insert.editFooter", &json!({"page": 0})).unwrap(); + let foot = r["story"].as_u64().unwrap() as u32; + s.run("text.insert", &json!({"text": "Foot"})).unwrap(); + s.run("insert.closeHeader", &json!({})).unwrap(); + assert_eq!(s.doc.last_section.footers.default, Some(foot)); + assert_eq!(s.layout().pages[1].footer_story, Some(foot)); + // Different first page: page 0's header is the first-page one, page 1's the default. + s.run("layout.differentFirstPage", &json!({"value": true})).unwrap(); + let first = s.run("insert.editHeader", &json!({"page": 0})).unwrap()["story"].as_u64().unwrap() as u32; + s.run("text.insert", &json!({"text": "Cover"})).unwrap(); + assert_eq!(s.doc.last_section.headers.first, Some(first)); + assert_eq!(s.doc.last_section.headers.default, None); + // Odd & even: page 1 (page number 2) gets the even header. + s.run("layout.differentOddEven", &json!({"value": true})).unwrap(); + let even = s.run("insert.editHeader", &json!({"page": 1})).unwrap()["story"].as_u64().unwrap() as u32; + s.run("text.insert", &json!({"text": "Even"})).unwrap(); + assert_eq!(s.doc.last_section.headers.even, Some(even)); + // A page that shows a header edits that one; a junk page is an error, not a panic. + assert_eq!(s.run("insert.editHeader", &json!({"page": 0})).unwrap()["story"].as_u64(), Some(first as u64)); + assert!(s.run("insert.editHeader", &json!({"page": 99})).is_err()); + assert!(s.run("insert.editHeader", &json!({"page": u64::MAX})).is_err()); + } + + fn two_pages() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "One"})).unwrap(); + s.run("insert.pageBreak", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Two"})).unwrap(); + s + } + + fn headers(s: &Session) -> usize { + s.doc.parts.values().filter(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)).count() + } + + /// #960 review: making a header is an edit — a read-only document refuses it (an existing + /// header can still be gone into), and Undo takes a made one away again. + #[test] + fn creating_a_header_is_an_undoable_protected_edit() { + let mut s = two_pages(); + s.doc.settings.protection = Some("readOnly".into()); + assert!(s.run("insert.editHeader", &json!({"page": 0})).is_err(), "read only: no new header"); + assert!(s.run("insert.editFooter", &json!({})).is_err(), "read only: no new footer"); + assert_eq!(headers(&s), 0); + s.doc.settings.protection = None; + let id = s.run("insert.editHeader", &json!({"page": 1})).unwrap()["story"].as_u64().unwrap() as u32; + assert!(s.doc.parts.contains_key(&id)); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(headers(&s), 0, "undo takes the new header away"); + assert_eq!(s.doc.last_section.headers, Default::default()); + // Going into one that is there is no edit: allowed read-only, and no undo step. + let id = s.run("insert.editHeader", &json!({"page": 0})).unwrap()["story"].as_u64().unwrap() as u32; + s.run("text.insert", &json!({"text": "Head"})).unwrap(); + s.run("insert.closeHeader", &json!({})).unwrap(); + let steps = s.undo_labels().len(); + s.doc.settings.protection = Some("readOnly".into()); + assert_eq!(s.run("insert.editHeader", &json!({"page": 1})).unwrap()["story"].as_u64(), Some(id as u64)); + assert_eq!(s.undo_labels().len(), steps); + } + + /// #960 review: a new header still empty isn't saved, even while it is open; and one an Undo + /// brings back after closing is taken away on the next close. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn an_empty_new_header_is_never_saved() { + let dir = std::env::temp_dir().join(format!("wordcraft-hf960-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("x.docx"); + let mut s = two_pages(); + s.run("insert.editHeader", &json!({"page": 0})).unwrap(); + s.run("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let back = wordcraft_docx::read(&std::fs::read(&path).unwrap()).unwrap(); + assert!(back.parts.values().all(|p| p.kind != PartKind::Header), "no empty header saved"); + assert_eq!(headers(&s), 1, "still open for typing"); + s.run("insert.closeHeader", &json!({})).unwrap(); + assert_eq!(headers(&s), 0); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(headers(&s), 1, "undo brings it back"); + s.run("insert.closeHeader", &json!({})).unwrap(); + assert_eq!(headers(&s), 0, "and the next close takes it away again"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #960 review: with no header anywhere, one made on a later section's page goes in the first + /// section, linked through to the later ones (Word's "Link to Previous" default); with Odd & + /// Even on and only a default header, an even page edits the one it shows. + #[test] + fn new_header_links_to_previous_and_even_pages_edit_what_they_show() { + let mut s = two_pages(); + s.run("layout.break", &json!({"kind": "nextPage"})).unwrap(); + s.run("text.insert", &json!({"text": "Three"})).unwrap(); + let pages = s.layout().pages.len(); + assert!(pages >= 3 && s.layout().pages[pages - 1].section == 1, "a second section"); + let id = s.run("insert.editHeader", &json!({"page": pages - 1})).unwrap()["story"].as_u64().unwrap() as u32; + s.run("text.insert", &json!({"text": "All"})).unwrap(); + s.run("insert.closeHeader", &json!({})).unwrap(); + assert!(s.layout().pages.iter().all(|p| p.header_story == Some(id)), "every page shows it"); + assert_eq!(s.doc.sections()[0].1.headers.default, Some(id)); + s.run("layout.differentOddEven", &json!({"value": true})).unwrap(); + assert_eq!(s.layout().pages[1].header_story, Some(id)); + assert_eq!(s.run("insert.editHeader", &json!({"page": 1})).unwrap()["story"].as_u64(), Some(id as u64)); + assert_eq!(s.doc.sections()[0].1.headers.even, None, "nothing made"); + } + #[test] fn base64_round_trip() { let data = b"hello world!?"; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 722f9c694..fde1b3d2d 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -357,6 +357,10 @@ pub struct Session { pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>, /// Last message for the status bar / agents. pub status: String, + /// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none + /// (#960): `insert.closeHeader` takes those still empty away again, as Word does, and saving + /// leaves them out. Kept after that, so one an undo brings back goes again on the next close. + pub new_header_parts: Vec, /// Undo and redo steps. Shared so `run` can snapshot both stacks cheaply (a pointer per /// step) and put them back exactly when a command fails. history: Vec>, @@ -587,6 +591,7 @@ impl Session { initials: String::new(), painter: None, status: String::new(), + new_header_parts: Vec::new(), history: Vec::new(), undo_evicted: 0, redo: Vec::new(), @@ -971,6 +976,7 @@ impl Session { self.column_mode = false; self.widened = None; self.edit_marks.clear(); + self.new_header_parts.clear(); self.go_back_step = 0; self.view.outline.collapsed.clear(); self.reset_history(); @@ -1019,6 +1025,8 @@ impl Session { initials: _, painter: _, status: _, + // Headers just created to edit: closing checks they still exist and are empty. + new_header_parts: _, history, undo_evicted, redo, diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 12b75ac8a..03246746f 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -321,12 +321,23 @@ impl DocLayout { /// Which header/footer (if any) is at (x, y) on a page — for double-click editing. pub fn header_footer_at(&self, page: usize, y: f32) -> Option<(StoryRef, bool)> { + let (story, header) = self.header_footer_zone(page, y)?; + story.map(|s| (s, header)) + } + + /// Whether y on `page` is in its header area (above the body: `Some((_, true))`) or footer + /// area (below it: `Some((_, false))`), with the story shown there — `None` when the page + /// has no header/footer yet (double-clicking there creates one, #960). + pub fn header_footer_zone(&self, page: usize, y: f32) -> Option<(Option, bool)> { let p = self.pages.get(page)?; + if !y.is_finite() { + return None; + } if y < p.body.y { - return p.header_story.map(|id| (StoryRef::Part(id), true)); + return Some((p.header_story.map(StoryRef::Part), true)); } if y > p.body.bottom() { - return p.footer_story.map(|id| (StoryRef::Part(id), false)); + return Some((p.footer_story.map(StoryRef::Part), false)); } None } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 883011b10..3c07ec412 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -405,6 +405,17 @@ pub fn turn_rect(turn: TextDirection, x: f32, y: f32, r: Rect) -> Rect { } } +/// Which header/footer of its section a page shows ([`Page::hf_slot`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HfSlot { + #[default] + Default, + /// The first page of a section with "Different First Page". + First, + /// An even page with "Different Odd & Even Pages". + Even, +} + /// One laid-out page. #[derive(Clone, Debug, Default)] pub struct Page { @@ -422,6 +433,10 @@ pub struct Page { pub body: Rect, pub header_story: Option, pub footer_story: Option, + /// Which of its section's headers/footers this page shows (first page, even page or the + /// default), whether or not the section has one: double-clicking an empty header area + /// creates that one (#960). + pub hf_slot: HfSlot, /// Top-level body block index at the start of the page. pub first_block: usize, /// Width of the markup area on the right of `w` that holds comment balloons (Print Markup, @@ -3372,6 +3387,13 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti }; let hid = pick(|s| s.headers); let fid = pick(|s| s.footers); + page.hf_slot = if sect.title_page && first_of_section.contains(&i) { + HfSlot::First + } else if even_odd && page.number % 2 == 0 { + HfSlot::Even + } else { + HfSlot::Default + }; ctx.fields.page = page.number; ctx.fields.page_format = sect.page_num_format; ctx.fields.pages = total; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 49860fe0a..d3092bd7b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1813,20 +1813,26 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo app.canvas.dragging = false; return; } - // Double-click in the header/footer area edits it; double-click in the body leaves it. + // Double-click in the header/footer area edits it (creating it when the page has none, #960); + // double-click in the body leaves it. Print Layout only: the other views show no margins. + let hf_zone = if app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { + layout.header_footer_zone(page, y) + } else { + None + }; if resp.double_clicked() { - if let Some((s, header)) = layout.header_footer_at(page, y) + if let Some((_, header)) = hf_zone && story == StoryRef::Body { let id = if header { "insert.editHeader" } else { "insert.editFooter" }; - let _ = app.run(id, json!({})); - let _ = s; - if let Some(pos) = app.session.layout().hit(page, x, y, app.session.sel.focus.story) { + if app.run(id, json!({ "page": page })).is_ok() + && let Some(pos) = app.session.layout().hit(page, x, y, app.session.sel.focus.story) + { app.session.sel = wordcraft_engine::Selection::caret(pos); } return; } - if matches!(story, StoryRef::Part(_)) && !in_text_box(app) && layout.header_footer_at(page, y).is_none() { + if matches!(story, StoryRef::Part(_)) && !in_text_box(app) && hf_zone.is_none() { let _ = app.run("insert.closeHeader", json!({})); if let Some(pos) = layout.hit(page, x, y, StoryRef::Body) { app.session.sel = wordcraft_engine::Selection::caret(pos); @@ -1856,8 +1862,8 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo app.canvas.last_press = Some((now, p, clicks)); app.canvas.unit_drag = None; // A double-click that enters or leaves a header/footer is handled on release. - let hf_switch = (layout.header_footer_at(page, y).is_some() && story == StoryRef::Body) - || (matches!(story, StoryRef::Part(_)) && !in_text_box(app) && layout.header_footer_at(page, y).is_none()); + let hf_switch = + (hf_zone.is_some() && story == StoryRef::Body) || (matches!(story, StoryRef::Part(_)) && !in_text_box(app) && hf_zone.is_none()); // Clicking into a footnote/endnote or a text box edits it; clicking outside a text box goes // back to where it is. While editing a header/footer, only its own text boxes are in reach. let story = { @@ -3156,6 +3162,77 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(StoryRef::Part(part)), "Original"); } + /// #960: double-clicking the top margin of a document without a header creates one and + /// edits it (the bottom margin: a footer), as in Word; typing goes into it, and + /// double-clicking the body leaves it. One left empty is taken away again. + #[test] + fn double_click_margin_creates_header_and_footer() { + use egui::{Event, PointerButton}; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Body text"})).unwrap(); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 2200.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let double_click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| { + h.event(Event::PointerMoved(at)); + for _ in 0..2 { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.step(); + } + // Let the double-click time pass before the next one. + for _ in 0..40 { + h.step(); + } + }; + let kind = |h: &egui_kittest::Harness<'_, WordApp>| part_kind(h.state(), h.state().session.sel.focus.story); + let page = h.state_mut().session.layout().pages[0].clone(); + assert!(page.header_story.is_none() && page.footer_story.is_none()); + let (x, top, bottom) = (page.body.x + 20.0, page.body.y / 2.0, (page.body.bottom() + page.h) / 2.0); + let body = page_to_screen(h.state_mut(), 0, x, page.body.y + 6.0).expect("body on screen"); + // Top margin: a new header, the caret in it, typing goes there. + let at = page_to_screen(h.state_mut(), 0, x, top).expect("top margin on screen"); + double_click(&mut h, at); + assert_eq!(kind(&h), Some(PartKind::Header), "editing a new header"); + let header = h.state().session.sel.focus.story; + h.event(Event::Text("Running head".into())); + for _ in 0..3 { + h.step(); + } + assert_eq!(h.state().session.doc.plain_text(header), "Running head"); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "Body text"); + // Double-click the body: back in it, the header kept. + double_click(&mut h, body); + assert_eq!(h.state().session.sel.focus.story, StoryRef::Body); + assert_eq!(h.state_mut().session.layout().pages[0].header_story.map(StoryRef::Part), Some(header)); + // Bottom margin: a new footer; left empty, leaving takes it away again. + let at = page_to_screen(h.state_mut(), 0, x, bottom).expect("bottom margin on screen"); + double_click(&mut h, at); + assert_eq!(kind(&h), Some(PartKind::Footer), "editing a new footer"); + double_click(&mut h, body); + assert_eq!(h.state().session.sel.focus.story, StoryRef::Body); + assert!(h.state().session.doc.last_section.footers.default.is_none(), "the empty footer is gone"); + // And a written footer stays. + double_click(&mut h, at); + let footer = h.state().session.sel.focus.story; + assert_eq!(kind(&h), Some(PartKind::Footer)); + h.event(Event::Text("Page foot".into())); + h.step(); + double_click(&mut h, body); + assert_eq!(h.state().session.sel.focus.story, StoryRef::Body); + assert_eq!(h.state().session.doc.plain_text(footer), "Page foot"); + } + /// #513: right-clicking a picture selects it and offers Wrap Text, as in Word; choosing /// Square there wraps text around it. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 843b08741..0e5811954 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-13 · **Change:** 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-13 · **Change:** trivial (double-click the margin to create and edit a header or footer, #960; 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 @@ -29,6 +29,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 | +| Headers and footers on the page: double-click the top or bottom margin to edit them, the body to leave | ✅ in Print Layout, also on a page with none yet: the header or footer that page shows is created (first page, even page or the default) and the caret put in it; one left empty is taken away again on leaving and is never saved (#960). Making one is an undo step, refused in a protected document. On a later section's page with no header anywhere it goes in the first section and shows on every section linked to it, as Word links to the previous section by default. Web Layout, Draft and Read Mode show no margins and leave it alone | ✅ | 95% | 0.5 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | 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 | reading-optimized view and navigation | 75% | 1–2 | @@ -136,3 +137,4 @@ buttons, the remaining labels start with zero and use the same number of digits `01`, `02` on a ten-button toolbar; `001` through `011` on a twenty-button toolbar). This keeps labels unambiguous. Those extra-number assignments have not been compared with Word. | 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) | +| 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 | From 086d3d0384b0b7a693cafc908ff1e7a61d9a0968 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 18:49:57 +0200 Subject: [PATCH 038/199] Alt+numpad character codes (#961) (#1146) * #961: Alt+numpad decimal character codes (ANSI, OEM 437/850, Unicode) Hold Alt, type a decimal code, release Alt: the character is typed at the caret through the new text.typeCode command. A leading 0 means Windows-1252, no leading 0 the OEM code page (437, or 850 for Western European locales), above 255 the Unicode character. Not on macOS. Co-Authored-By: Claude Opus 5.5 * #961: Alt codes follow the system code pages; ignore auto-repeat, drop on blur text.typeCode takes optional ansi (874, 1250-1258) and oem (437, 737, 850, 852, 857, 866) code pages; OEM 852/866/737/857 tables added from the public mapping files. On Windows the shell passes the system locale's pages and, for codes up to 255, lets the character Windows composes replace ours (above 255 ours stays and Windows' byte-reduced text isn't typed). Auto-repeat of a held digit adds nothing, and losing focus drops a half-typed code. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 +- crates/doc/src/encoding.rs | 126 +++++++++++++ crates/engine/src/cmd/text.rs | 109 ++++++++++++ crates/engine/src/session.rs | 5 + crates/engine/src/tests_typing.rs | 96 ++++++++++ crates/ui-egui/src/canvas.rs | 10 ++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr-latn.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/keys.rs | 262 +++++++++++++++++++++++++++- docs/typing-parity.md | 4 +- 26 files changed, 630 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9b140cd3e..f96842a2b 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-13 · **Change:** trivial (progress log: interface audit back in CI, #1128; previously trivial: progress log: double-click the margin for a new header or footer, #960; 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-13 · **Change:** trivial (progress log: interface audit back in CI, #1128; previously trivial: progress log: Alt+numpad character codes, #961; 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. @@ -149,6 +149,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | +| 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/crates/doc/src/encoding.rs b/crates/doc/src/encoding.rs index 818ef39bc..127812df3 100644 --- a/crates/doc/src/encoding.rs +++ b/crates/doc/src/encoding.rs @@ -125,6 +125,114 @@ const CP1258: [u16; 128] = [ 0x20AB, 0x00FF, ]; +/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437 +/// mapping file). +const CP437: [u16; 128] = [ + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6, + 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA, + 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, + 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, + 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, + 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, + 0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, + 0x25A0, 0x00A0, +]; + +/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode +/// Consortium's CP850 mapping file). +const CP850: [u16; 128] = [ + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6, + 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA, + 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1, + 0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3, + 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518, + 0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9, + 0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2, + 0x25A0, 0x00A0, +]; + +/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852 +/// mapping file). +const CP852: [u16; 128] = [ + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139, + 0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA, + 0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1, + 0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103, + 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518, + 0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170, + 0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159, + 0x25A0, 0x00A0, +]; + +/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866 +/// mapping file). +const CP866: [u16; 128] = [ + 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, + 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, + 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, + 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, + 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, + 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, + 0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4, + 0x25A0, 0x00A0, +]; + +/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737 +/// mapping file). +const CP737: [u16; 128] = [ + 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3, + 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC, + 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, + 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, + 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, + 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388, + 0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, + 0x25A0, 0x00A0, +]; + +/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857 +/// mapping file). +const CP857: [u16; 128] = [ + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6, + 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA, + 0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1, + 0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3, + 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518, + 0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9, + 0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2, + 0x25A0, 0x00A0, +]; + +/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as +/// Windows' Alt+numpad entry produces them rather than the control characters. +const OEM_GLYPHS: [u16; 31] = [ + 0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195, + 0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC, +]; + +/// The OEM code pages [`oem_byte`] decodes. +pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866]; + +/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a +/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL. +pub fn oem_byte(code_page: u16, b: u8) -> char { + let table: &[u16; 128] = match code_page { + 737 => &CP737, + 850 => &CP850, + 852 => &CP852, + 857 => &CP857, + 866 => &CP866, + _ => &CP437, + }; + let u = match b { + 0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(), + 0x7F => Some(0x2302), + 0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(), + _ => Some(u16::from(b)), + }; + u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}') +} + /// The single-byte Windows code pages [`windows_byte`] decodes (1252 included). pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258]; @@ -188,4 +296,22 @@ mod tests { } } } + + #[test] + fn oem_code_pages() { + let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::(); + assert_eq!(decode(437, &[0x82, 0xE9, 0x01, 0x7F]), "éΘ☺⌂"); + assert_eq!(decode(850, &[0x9B, 0xE9]), "øÚ"); + assert_eq!(decode(852, &[0xA5, 0x88, 0xBE, 0xA9]), "ąłżę"); + assert_eq!(decode(866, &[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, 0xF0, 0xFC]), "ПриветЁ№"); + assert_eq!(decode(737, &[0x80, 0x99, 0xE0]), "Αβω"); + assert_eq!(decode(857, &[0x8D, 0x98, 0xA6, 0x9F]), "ıİĞş"); + assert_eq!(oem_byte(857, 0xD5), '\u{FFFD}', "unassigned in 857"); + assert_eq!(decode(9999, &[0xE9]), "Θ", "unknown pages read as 437"); + for cp in OEM_CODE_PAGES { + for b in 0..=255u8 { + let _ = oem_byte(cp, b); + } + } + } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 23b79c97f..a4fb4c7b4 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -30,6 +30,9 @@ pub fn specs() -> Vec { } else { "Alt+X" }), + // Alt+numpad codes: the shell collects the keypad digits while Alt is held (#961). + CommandSpec::new("text.typeCode", "Type Character Code", "Editing", insert_code) + .params(r#"{"code": string (decimal digits as typed on the keypad, e.g. "0233"), "ansi"?: 874 | 1250–1258 (the leading-0 code page, default 1252), "oem"?: 437 | 737 | 850 | 852 | 857 | 866 (default from the locale)}"#), CommandSpec::new("text.emDash", "Em Dash", "Insert › Symbols", |s, _| ins_char(s, '—')).key("Mod+Alt+-"), // On macOS Option+G / R / 2 / ; type these directly, so Word for Mac has no Cmd shortcut. CommandSpec::new("text.copyright", "Copyright Sign", "Insert › Symbols", |s, _| ins_char(s, '©')).key(non_mac("Mod+Alt+C")), @@ -361,6 +364,112 @@ fn toggle_unicode(s: &mut Session, _: &Value) -> CmdResult { sel_result(s) } +/// Alt+numpad entry: type the character a decimal code names at the caret, as typed text (one +/// undo step, AutoFormat as for a typed character). `ansi` and `oem` are the system's code pages +/// (Windows-1252 and the locale's OEM page by default). The result's `char` is the character. +fn insert_code(s: &mut Session, v: &Value) -> CmdResult { + use wordcraft_doc::encoding::{OEM_CODE_PAGES, SINGLE_BYTE_CODE_PAGES}; + let code = p::req_str(v, "code")?; + let page = |key: &str, known: &[u16], default: u16| match p::u64(v, key) { + Some(cp) => u16::try_from(cp).ok().filter(|cp| known.contains(cp)).ok_or_else(|| CmdError::Params(format!("{key} must be one of {known:?}"))), + None => Ok(default), + }; + let ansi = page("ansi", &SINGLE_BYTE_CODE_PAGES, 1252)?; + let oem = page("oem", &OEM_CODE_PAGES, system_oem_code_page())?; + let c = alt_code_char(code, ansi, oem).ok_or_else(|| CmdError::Failed(format!("{code:?} isn't a character code")))?; + let mut r = insert(s, &serde_json::json!({"text": c.to_string()}))?; + if let Value::Object(m) = &mut r { + m.insert("char".into(), Value::String(c.to_string())); + } + Ok(r) +} + +/// The character Word on Windows types for an Alt+numpad code (decimal digits, as typed): +/// - with a leading 0 and a value up to 255, the ANSI code page's character (`ansi`, one of +/// [`wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES`]): `0233` → é and `0128` → € in +/// Windows-1252, `0185` → ą in 1250; +/// - without one, 1–255 is the OEM code page's character (`oem`, one of +/// [`wordcraft_doc::encoding::OEM_CODE_PAGES`]): `233` → Θ in 437, Ú in 850 (é is `130` in +/// both), `165` → ą in 852; 1–31 and 127 are the code page's glyphs (`1` → ☺, `7` → •); +/// - above 255, with or without the 0, the Unicode character with that decimal value +/// (`8364` → €), as Word inserts it. +/// +/// `None` for an empty or non-numeric code, 0, a control character, a byte the code page leaves +/// unassigned or a value past Unicode. +pub fn alt_code_char(code: &str, ansi: u16, oem: u16) -> Option { + if code.is_empty() || !code.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let digits = code.trim_start_matches('0'); + // More than seven significant digits is past U+10FFFF (1114111). + if digits.len() > 7 { + return None; + } + let value: u32 = if digits.is_empty() { 0 } else { digits.parse().ok()? }; + let c = match u8::try_from(value) { + Ok(0) => return None, + Ok(b) if code.starts_with('0') => wordcraft_doc::encoding::windows_byte(ansi, b), + Ok(b) => wordcraft_doc::encoding::oem_byte(oem, b), + Err(_) => char::from_u32(value)?, + }; + Some(c).filter(|c| codeable(*c) && *c != '\u{FFFD}') +} + +/// The OEM code page for the system locale the shell reported (437 headless). +pub fn system_oem_code_page() -> u16 { + oem_code_page(crate::session::system_locale()) +} + +/// The (ANSI, OEM) code pages of the system locale the shell reported, as Windows sets them +/// ([`windows_code_pages`]). +pub fn system_code_pages() -> (u16, u16) { + windows_code_pages(crate::session::system_locale()) +} + +/// The OEM code page `text.typeCode` assumes for a locale (BCP 47 tag) when the shell names +/// none: 850 for Western European languages and English outside the US, 437 otherwise. The +/// other pages come only from the shell (Windows passes [`windows_code_pages`]). +pub fn oem_code_page(locale: Option<&str>) -> u16 { + match windows_code_pages(locale) { + (_, 850) => 850, + _ => 437, + } +} + +/// The (ANSI, OEM) code pages Windows uses for a locale (BCP 47 tag or POSIX name), limited to +/// the pages `text.typeCode` decodes: Windows-1252 and OEM 437 for US English, unknown locales +/// and those with other pages (Asian double-byte pages, OEM 775/720/862…). +pub fn windows_code_pages(locale: Option<&str>) -> (u16, u16) { + let Some(tag) = locale else { return (1252, 437) }; + let parts: Vec<&str> = tag.split(['-', '_', '.', '@']).collect(); + let lang = parts.first().copied().unwrap_or_default().to_ascii_lowercase(); + let rest = parts.get(1..).unwrap_or_default(); + let region = rest.iter().find(|p| p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())).map(|r| r.to_ascii_uppercase()); + let script = rest.iter().find(|p| p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic())).map(|r| r.to_ascii_lowercase()); + const WESTERN: [&str; 18] = ["ca", "da", "de", "es", "eu", "fi", "fo", "fr", "ga", "gl", "is", "it", "nb", "nl", "nn", "no", "pt", "sv"]; + match lang.as_str() { + "en" => match region.as_deref() { + None | Some("US") => (1252, 437), + Some(_) => (1252, 850), + }, + l if WESTERN.contains(&l) => (1252, 850), + "pl" | "cs" | "sk" | "hu" | "sl" | "hr" | "ro" | "sq" | "bs" => (1250, 852), + "sr" if script.as_deref() == Some("latn") => (1250, 852), + "ru" | "uk" | "be" | "bg" => (1251, 866), + // Macedonian and Cyrillic Serbian use OEM 855, which isn't decoded. + "sr" | "mk" => (1251, 437), + "el" => (1253, 737), + "az" if script.as_deref() == Some("cyrl") => (1251, 866), + "tr" | "az" => (1254, 857), + "he" => (1255, 437), + "ar" | "fa" | "ur" => (1256, 437), + "et" | "lv" | "lt" => (1257, 437), + "vi" => (1258, 437), + "th" => (874, 437), + _ => (1252, 437), + } +} + /// The character a hex code (1–6 digits) names, if it is one Alt+X makes. fn hex_char(code: &str) -> Option { if code.is_empty() || code.len() > 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fde1b3d2d..f3b45a4d5 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -11,6 +11,11 @@ pub fn set_system_locale(tag: &str) { let _ = SYSTEM_LOCALE.set(tag.to_string()); } +/// The system locale [`set_system_locale`] recorded, if the shell set one. +pub(crate) fn system_locale() -> Option<&'static str> { + SYSTEM_LOCALE.get().map(String::as_str) +} + use serde::{Deserialize, Serialize}; use serde_json::Value; use wordcraft_doc::edit::Fragment; diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 70e75be47..1a1f5014f 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -742,6 +742,102 @@ fn alt_x_toggles_between_a_character_and_its_code() { assert_eq!(texts(&s), ["x é 😀 —"]); } +#[test] +fn alt_numpad_codes_map_like_word_on_windows() { + use crate::cmd::text::{alt_code_char, oem_code_page}; + let c = |code| alt_code_char(code, 1252, 437); + // Leading 0: Windows-1252. + assert_eq!(c("0233"), Some('é')); + assert_eq!(c("0128"), Some('€')); + assert_eq!(c("0150"), Some('–')); + assert_eq!(c("065"), Some('A')); + assert_eq!(c("0032"), Some(' ')); + assert_eq!(c("0255"), Some('ÿ')); + // No leading 0: the OEM code page (437 here, 850 in Western Europe). + assert_eq!(c("233"), Some('Θ')); + // The issue said 233 → é in 850; the CP850 mapping file has Ú at 0xE9 (é is 130 in both). + assert_eq!(alt_code_char("233", 1252, 850), Some('Ú')); + assert_eq!(alt_code_char("130", 1252, 850), Some('é')); + assert_eq!(c("130"), Some('é')); + assert_eq!(alt_code_char("155", 1252, 850), Some('ø')); + assert_eq!(c("155"), Some('¢')); + assert_eq!(c("65"), Some('A')); + assert_eq!(c("1"), Some('☺')); + assert_eq!(c("7"), Some('•')); + assert_eq!(c("127"), Some('⌂')); + assert_eq!(c("176"), Some('░')); + // Above 255: the Unicode character with that decimal value. + assert_eq!(c("8364"), Some('€')); + assert_eq!(c("09733"), Some('★')); + assert_eq!(c("128512"), Some('😀')); + // Nothing for junk, 0, control characters, surrogates or values past Unicode. + for bad in ["", "0", "000", "0009", "0013", "0127", "0129", "x1", "-5", "55296", "1114112", "99999999999999999999", "4294967361"] { + assert_eq!(c(bad), None, "{bad:?}"); + } + assert_eq!(oem_code_page(None), 437); + assert_eq!(oem_code_page(Some("en-US")), 437); + assert_eq!(oem_code_page(Some("en_GB.UTF-8")), 850); + assert_eq!(oem_code_page(Some("fr-FR")), 850); + assert_eq!(oem_code_page(Some("de")), 850); + assert_eq!(oem_code_page(Some("ru-RU")), 437, "only 437/850 without the shell's code pages"); +} + +#[test] +fn alt_numpad_codes_follow_the_system_code_pages() { + use crate::cmd::text::{alt_code_char, windows_code_pages}; + // Polish (1250/852): Alt+0185 and Alt+165 are both ą, not ¹ / Ñ. + assert_eq!(alt_code_char("0185", 1250, 852), Some('ą')); + assert_eq!(alt_code_char("165", 1250, 852), Some('ą')); + assert_eq!(alt_code_char("0185", 1252, 437), Some('¹')); + // Russian (1251/866), Greek (1253/737), Turkish (1254/857). + assert_eq!(alt_code_char("0207", 1251, 866), Some('П')); + assert_eq!(alt_code_char("143", 1251, 866), Some('П')); + assert_eq!(alt_code_char("0193", 1253, 737), Some('Α')); + assert_eq!(alt_code_char("128", 1253, 737), Some('Α')); + assert_eq!(alt_code_char("0253", 1254, 857), Some('ı')); + assert_eq!(alt_code_char("141", 1254, 857), Some('ı')); + // Above 255 the code page doesn't matter. + assert_eq!(alt_code_char("8364", 1251, 866), Some('€')); + // A byte the code page leaves unassigned types nothing. + assert_eq!(alt_code_char("213", 1254, 857), None); + assert_eq!(alt_code_char("0219", 874, 437), None); + assert_eq!(windows_code_pages(None), (1252, 437)); + assert_eq!(windows_code_pages(Some("en-US")), (1252, 437)); + assert_eq!(windows_code_pages(Some("de-DE")), (1252, 850)); + assert_eq!(windows_code_pages(Some("pl-PL")), (1250, 852)); + assert_eq!(windows_code_pages(Some("sr-Latn-RS")), (1250, 852)); + assert_eq!(windows_code_pages(Some("ru_RU.UTF-8")), (1251, 866)); + assert_eq!(windows_code_pages(Some("el-GR")), (1253, 737)); + assert_eq!(windows_code_pages(Some("tr-TR")), (1254, 857)); + assert_eq!(windows_code_pages(Some("ja-JP")), (1252, 437)); +} + +#[test] +fn alt_numpad_code_types_one_character() { + // `texts` lowercases; the capitals matter here. + let raw = |s: &Session| paras(s).into_iter().map(|p| p.0).collect::>(); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "caf"})); + run(&mut s, "text.typeCode", json!({"code": "0233"})); + run(&mut s, "text.insert", json!({"text": " "})); + run(&mut s, "text.typeCode", json!({"code": "233", "oem": 437})); + run(&mut s, "text.insert", json!({"text": " "})); + run(&mut s, "text.typeCode", json!({"code": "155", "oem": 850})); + assert_eq!(raw(&s), ["Café Θ ø"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(raw(&s), ["Café Θ "], "one undo step per code"); + assert!(s.run("text.typeCode", &json!({"code": "0"})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "65", "oem": 855})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 932})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 70000})).is_err()); + assert_eq!(raw(&s), ["Café Θ "]); + // The system's code pages: Polish Alt+0185 and Alt+165 both type ą. + run(&mut s, "text.typeCode", json!({"code": "0185", "ansi": 1250, "oem": 852})); + run(&mut s, "text.typeCode", json!({"code": "165", "ansi": 1250, "oem": 852})); + run(&mut s, "text.typeCode", json!({"code": "143", "oem": 866})); + assert_eq!(raw(&s), ["Café Θ ąąП"]); +} + #[test] fn shift_f5_goes_back_through_the_last_edits() { let mut s = typed("One\nTwo\nThree"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d3092bd7b..7c2385615 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -133,6 +133,11 @@ 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, + /// 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 + /// character as text on the Alt release (Windows does), it isn't typed twice. + pub(crate) alt_code_echo: Option, /// Create Link is waiting for a click on the text box to link to: the linking box's object. pub(crate) link_from: Option, /// What screen readers were last told about the document (#488). @@ -193,6 +198,8 @@ impl Default for CanvasState { mini_anchor: None, pasted: false, swallow_text: false, + alt_code: String::new(), + alt_code_echo: None, wheel: Default::default(), auto_scroll: Default::default(), scroll_offset: egui::Vec2::ZERO, @@ -728,6 +735,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // While "Save changes?" is up, keys answer it rather than edit the document behind it. if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) { crate::keys::canvas_events(app, ui.ctx()); + } else { + // Alt+numpad entry doesn't survive the canvas losing the keyboard. + app.canvas.alt_code.clear(); } crate::ink::replay_tick(app, ui.ctx()); let layout = app.session.layout(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ac3123c78..c2d5b0ddc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1349,6 +1349,7 @@ Demote Понижаване на нивото Go Back Връщане назад Toggle Unicode Character Превключване на Unicode знак + Type Character Code Въвеждане на код на знак Toggle Field Codes Превключване на кодовете на полетата Edit Field Code Редактиране на кода на полето Unlink Fields Прекъсване на връзката на полетата diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 425d1a325..eaf7e00be 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1343,6 +1343,7 @@ Demote Snížit úroveň Go Back Přejít zpět Toggle Unicode Character Přepnout znak Unicode + Type Character Code Zadat kód znaku Toggle Field Codes Přepnout kódy polí Edit Field Code Upravit kód pole Unlink Fields Zrušit propojení polí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 209054cbb..512788019 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1351,6 +1351,7 @@ Demote Tiefer stufen Go Back Zurückspringen Toggle Unicode Character Unicode-Zeichen umschalten + Type Character Code Zeichencode eingeben Toggle Field Codes Feldfunktionen ein/aus Edit Field Code Feldfunktion bearbeiten Unlink Fields Feldverknüpfungen aufheben diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0a694a4f0..1ef9ae0ef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1449,6 +1449,7 @@ Demote Degradar Go Back Volver Toggle Unicode Character Alternar carácter Unicode + Type Character Code Escribir código de carácter Toggle Field Codes Alternar códigos de campo Edit Field Code Editar código de campo Unlink Fields Desvincular campos diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 8c6093c26..a810e771c 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1418,6 +1418,7 @@ Demote Langeta taset Go Back Mine tagasi Toggle Unicode Character Lülita Unicode-märk + Type Character Code Sisesta märgikood Toggle Field Codes Lülita väljakoodid sisse/välja Edit Field Code Redigeeri väljakoodi Unlink Fields Eemalda väljade link diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b774caa55..39ab205c6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1344,6 +1344,7 @@ Demote Abaisser le niveau Go Back Revenir en arrière Toggle Unicode Character Basculer le caractère Unicode + Type Character Code Saisir un code de caractère Toggle Field Codes Basculer les codes de champ Edit Field Code Modifier le code de champ Unlink Fields Dissocier les champs diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 934b9ee3b..cd5e6b347 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1753,6 +1753,7 @@ Selected Fields Kijelölt mezők Toggle Field Codes Mezőkódok átváltása Toggle Unicode Character Unicode-karakter átváltása + Type Character Code Karakterkód beírása Trademark Sign Védjegy jele Unlink Fields Mezők leválasztása Unlock Fields Mezők feloldása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6c12a47ba..927212063 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1455,6 +1455,7 @@ Square Incorniciato Through Passante Toggle Unicode Character Attiva/disattiva carattere Unicode + Type Character Code Digita codice carattere Top and Bottom Sopra e sotto Wrap Text Sides Lati del testo a capo Arrows Frecce diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 767b2d7c1..a2d3d4a99 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1409,6 +1409,7 @@ Demote レベル下げ Go Back 前の編集位置に戻る Toggle Unicode Character Unicode 文字の切り替え + Type Character Code 文字コードの入力 Toggle Field Codes フィールドコードの表示切り替え Edit Field Code フィールドコードの編集 Unlink Fields フィールドのリンク解除 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3551b816d..93364ea45 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1385,6 +1385,7 @@ Remove every custom shortcut and restore the built-in keys? Usunąć wszystkie niestandardowe skróty i przywrócić wbudowane klawisze? Reset All… Resetuj wszystko… Toggle Unicode Character Przełącz znak Unicode + Type Character Code Wpisz kod znaku (Custom) (Niestandardowe) << Remove << Usuń Add >> Dodaj >> diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9d65ad9cb..5b49b3dcb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1421,6 +1421,7 @@ Demote Rebaixar Go Back Voltar Toggle Unicode Character Alternar caractere Unicode + Type Character Code Digitar código de caractere Toggle Field Codes Alternar códigos de campo Edit Field Code Editar código de campo Unlink Fields Desvincular campos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 14307f562..e21c6ffc4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1735,6 +1735,7 @@ preview Title Пример Selected Fields Выделенные поля Toggle Field Codes Коды/значения полей Toggle Unicode Character Преобразовать знак Юникода + Type Character Code Ввести код символа Trademark Sign Знак товарного знака Unlink Fields Разорвать связь с полями Unlock Fields Разблокировать поля diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index da721fbd4..c0296b5f1 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1419,6 +1419,7 @@ Demote Spusti nivo Go Back Vrati se nazad Toggle Unicode Character Prebaci Unicode znak + Type Character Code Unesi kod znaka Toggle Field Codes Uključi/isključi kodove polja Edit Field Code Uredi kod polja Unlink Fields Raskini vezu polja diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 7441b294a..b4d69d54d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1423,6 +1423,7 @@ Demote Спусти ниво Go Back Врати се назад Toggle Unicode Character Пребаци Unicode знак + Type Character Code Унеси код знака Toggle Field Codes Укључи/искључи кодове поља Edit Field Code Уреди код поља Unlink Fields Раскини везу поља diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7300dfecf..349f2675a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1737,6 +1737,7 @@ preview Title Örnek Selected Fields Seçili Alanlar Toggle Field Codes Alan Kodlarını Aç/Kapat Toggle Unicode Character Unicode Karakterini Değiştir + Type Character Code Karakter Kodu Yaz Trademark Sign Ticari Marka İşareti Unlink Fields Alan Bağlantılarını Kaldır Unlock Fields Alanların Kilidini Aç diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb25ef54b..d9c933317 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1421,6 +1421,7 @@ Demote Понизити рівень Go Back Повернутися назад Toggle Unicode Character Перемкнути символ Unicode + Type Character Code Ввести код символу Toggle Field Codes Перемкнути коди полів Edit Field Code Змінити код поля Unlink Fields Від’єднати поля diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2fa628a14..3c42b08dc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1421,6 +1421,7 @@ Demote 降级 Go Back 返回上次编辑位置 Toggle Unicode Character 切换 Unicode 字符 + Type Character Code 输入字符代码 Toggle Field Codes 切换域代码 Edit Field Code 编辑域代码 Unlink Fields 取消域链接 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b1284fd27..ad9236bbb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1410,6 +1410,7 @@ Demote 降級 Go Back 返回上次編輯位置 Toggle Unicode Character 切換 Unicode 字元 + Type Character Code 輸入字元碼 Toggle Field Codes 切換功能變數代碼 Edit Field Code 編輯功能變數代碼 Unlink Fields 取消功能變數連結 diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index cde1ffd7c..a0caa6a71 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -229,13 +229,29 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } return; } + let alt_codes = alt_codes_supported(ctx); 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, + egui::Event::Key { pressed: true, .. } => { + app.canvas.swallow_text = false; + app.canvas.alt_code_echo = None; + } egui::Event::Text(_) if std::mem::take(&mut app.canvas.swallow_text) => continue, + egui::Event::Text(t) => { + if let Some(echo) = app.canvas.alt_code_echo.take() + && alt_code_echoed(app, &echo, t) + { + continue; + } + } + // Focus elsewhere (another window) ends an Alt code unfinished. + egui::Event::WindowFocused(false) => app.canvas.alt_code.clear(), _ => {} } + if alt_codes && app.session.math.is_none() && alt_code_event(app, &e) { + continue; + } if let egui::Event::Paste(t) = &e { app.canvas.pasted = true; // Copied picture files arrive as their paths: insert the pictures (#45). @@ -373,6 +389,138 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } } +/// Alt+numpad character codes are a Windows convention; on macOS Option+digit types its own +/// characters (¡ ™ £…), so they're off there (also in a browser on a Mac). +fn alt_codes_supported(ctx: &egui::Context) -> bool { + !cfg!(target_os = "macos") && ctx.os() != egui::os::OperatingSystem::Mac +} + +/// The digit a key types for an Alt code. +/// +/// Word counts only numeric-keypad digits. egui 0.36 reports keypad and top-row digits alike +/// (`Key::Num0`…`Num9`, with the same `physical_key`), and eframe passes no winit key location +/// on, so the top-row digits compose codes too (they type nothing else with Alt alone; Ctrl+Alt+1 +/// and keytip digits are untouched). +fn alt_code_digit(key: Key) -> Option { + let n = match key { + Key::Num0 => 0, + Key::Num1 => 1, + Key::Num2 => 2, + Key::Num3 => 3, + Key::Num4 => 4, + Key::Num5 => 5, + Key::Num6 => 6, + Key::Num7 => 7, + Key::Num8 => 8, + Key::Num9 => 9, + _ => return None, + }; + char::from_digit(n, 10) +} + +/// Alt+numpad entry (#961): digits typed with Alt alone held collect; releasing Alt types the +/// character their code names (`text.typeCode`: 0233 → é, 233 → Θ in code page 437). Returns +/// whether the event was part of it (and isn't handled otherwise). +fn alt_code_event(app: &mut WordApp, e: &egui::Event) -> bool { + let alt_only = |m: &Modifiers| m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd; + match e { + egui::Event::Key { key, pressed: true, repeat, modifiers, .. } if alt_only(modifiers) => match alt_code_digit(*key) { + // A held digit's auto-repeat adds nothing (as on Windows). + Some(_) if *repeat => { + app.canvas.swallow_text = true; + true + } + Some(d) => { + // Codes beyond seven digits name nothing; stop collecting rather than grow. + if app.canvas.alt_code.len() < 8 { + app.canvas.alt_code.push(d); + } + // The digit's text (Linux sends it even with Alt held) isn't typed. + app.canvas.swallow_text = true; + true + } + None => { + app.canvas.alt_code.clear(); + false + } + }, + egui::Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => { + finish_alt_code(app); + false + } + egui::Event::ModifiersChanged(m) if !m.alt => { + finish_alt_code(app); + false + } + egui::Event::Key { pressed: true, .. } => { + // Another key with Ctrl/Shift held (or without Alt) ends the entry unfinished. + app.canvas.alt_code.clear(); + false + } + _ => false, + } +} + +/// What an Alt code typed, for the platform's own composed character that may follow. +#[derive(Clone, Debug)] +pub(crate) struct AltCodeEcho { + /// The character typed. + typed: String, + /// The system composes Alt codes itself (Windows): whatever it sends is the code's character. + system_composes: bool, + /// The code named a byte of the system's code pages (1–255), so the system's character wins + /// over ours: it knows the code pages (DBCS ones too) better than our locale guess. + byte_code: bool, +} + +/// Alt released: type the collected code's character, if any. +fn finish_alt_code(app: &mut WordApp) { + let code = std::mem::take(&mut app.canvas.alt_code); + if code.is_empty() { + return; + } + crate::objects::enter_text_box(app); + let system_composes = cfg!(target_os = "windows"); + let mut params = json!({"code": code}); + if system_composes { + // Windows: the code pages of the system locale (Polish 1250/852, Russian 1251/866…). + let (ansi, oem) = wordcraft_engine::cmd::text::system_code_pages(); + params = json!({"code": code, "ansi": ansi, "oem": oem}); + // The system's composed character comes as text after the digits' swallowed text. + app.canvas.swallow_text = false; + } + if let Ok(r) = app.run("text.typeCode", params) { + let byte_code = code.trim_start_matches('0').parse::().is_ok_and(|v| v <= 255); + app.canvas.alt_code_echo = r.get("char").and_then(Value::as_str).map(|c| AltCodeEcho { typed: c.to_string(), system_composes, byte_code }); + } +} + +/// Text arriving right after an Alt code typed `echo` (no key pressed between): whether it was +/// the platform's composed character and is handled. The same character isn't typed twice. On +/// Windows the text is always the composed character: for a code up to 255 it replaces ours (the +/// system knows its code pages), above 255 ours stays (Windows reduces it to a byte; Word types +/// the Unicode character). +fn alt_code_echoed(app: &mut WordApp, echo: &AltCodeEcho, text: &str) -> bool { + if echo.typed == text { + return true; + } + if !echo.system_composes { + return false; + } + if echo.byte_code && text.chars().count() == 1 { + let s = &app.session; + let f = s.sel.focus.clone(); + let start = f.off.checked_sub(echo.typed.len()); + let ours = + start.filter(|_| s.sel.is_collapsed()).filter(|st| s.doc.para_at(&f).and_then(|p| p.text.get(*st..f.off)) == Some(echo.typed.as_str())); + if let Some(start) = ours { + app.session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos { off: start, ..f.clone() }, focus: f }; + let _ = app.run("text.insert", json!({"text": text})); + } + } + true +} + /// Whether a key runs its shortcut with the canvas unfocused (after a ribbon click, say): Mod and /// Alt shortcuts (Alt+Q reaches Search from anywhere, as in Word, #819) and the function keys. fn is_global_shortcut(key: Key, m: Modifiers) -> bool { @@ -452,4 +600,116 @@ mod tests { assert!(!is_global_shortcut(Key::Q, m(false, false, false)), "a plain letter is typing"); assert!(!is_global_shortcut(Key::Q, m(false, false, true)), "Shift+letter is typing"); } + + /// Alt held, keypad 0 2 3 3, Alt released: é is typed, the digits aren't, and keytips stay + /// off (#961). Linux sends each digit's text too; Windows may send the composed character. + #[test] + fn alt_numpad_code_types_its_character_without_keytips() { + if cfg!(target_os = "macos") { + return; + } + let doc_text = |a: &WordApp| a.session.doc.plain_text(wordcraft_doc::StoryRef::Body); + let ctx = egui::Context::default(); + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + let alt = Modifiers { alt: true, ..Default::default() }; + let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }; + let frame = |a: &mut WordApp, events: Vec| { + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::keytips::logic(a, ui.ctx()); + canvas_events(a, ui.ctx()); + }) + .drop_without_applying_deltas(); + }; + frame(&mut a, vec![egui::Event::ModifiersChanged(alt), key(Key::AltLeft, true, alt)]); + for (k, t) in [(Key::Num0, "0"), (Key::Num2, "2"), (Key::Num3, "3"), (Key::Num3, "3")] { + frame(&mut a, vec![key(k, true, alt), egui::Event::Text(t.into()), key(k, false, alt)]); + } + assert_eq!(doc_text(&a).trim_end(), "", "nothing typed while Alt is held"); + frame(&mut a, vec![key(Key::AltLeft, false, alt), egui::Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(doc_text(&a).trim_end(), "é"); + assert_eq!(a.ui.keytips, crate::keytips::Phase::Off, "an Alt code doesn't open keytips"); + // The platform's own composed character isn't typed twice; the next typing is. + frame(&mut a, vec![egui::Event::Text("é".into())]); + frame(&mut a, vec![key(Key::E, true, Modifiers::NONE), egui::Event::Text("é".into())]); + assert_eq!(doc_text(&a).trim_end(), "éé"); + // Without the leading zero: the system locale's OEM code page (233 is Θ in 437, Ú in 850). + frame(&mut a, vec![egui::Event::ModifiersChanged(alt), key(Key::AltLeft, true, alt)]); + for k in [Key::Num2, Key::Num3, Key::Num3] { + frame(&mut a, vec![key(k, true, alt), key(k, false, alt)]); + } + frame(&mut a, vec![key(Key::AltLeft, false, alt), egui::Event::ModifiersChanged(Modifiers::NONE)]); + let pages = wordcraft_engine::cmd::text::system_code_pages(); + let oem = wordcraft_engine::cmd::text::alt_code_char( + "233", + 1252, + if cfg!(target_os = "windows") { pages.1 } else { wordcraft_engine::cmd::text::system_oem_code_page() }, + ); + assert_eq!(doc_text(&a).trim_end(), format!("éé{}", oem.unwrap_or('?'))); + // A bare Alt tap still opens keytips. + frame(&mut a, vec![egui::Event::ModifiersChanged(alt), key(Key::AltLeft, true, alt)]); + frame(&mut a, vec![key(Key::AltLeft, false, alt), egui::Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(a.ui.keytips, crate::keytips::Phase::Tabs); + } + + fn alt_frame_app() -> (egui::Context, WordApp) { + let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + (egui::Context::default(), a) + } + + fn alt_frame(ctx: &egui::Context, a: &mut WordApp, events: Vec) { + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| canvas_events(a, ui.ctx())).drop_without_applying_deltas(); + } + + fn alt_key(key: Key, pressed: bool, repeat: bool) -> egui::Event { + egui::Event::Key { key, physical_key: Some(key), pressed, repeat, modifiers: Modifiers { alt: true, ..Default::default() } } + } + + /// Holding a digit with Alt adds it once: its auto-repeat presses don't (#961). + #[test] + fn alt_code_ignores_auto_repeat() { + if cfg!(target_os = "macos") { + return; + } + let (ctx, mut a) = alt_frame_app(); + alt_frame(&ctx, &mut a, vec![alt_key(Key::Num6, true, false), alt_key(Key::Num6, true, true), alt_key(Key::Num6, true, true)]); + alt_frame(&ctx, &mut a, vec![alt_key(Key::Num5, true, false)]); + assert_eq!(a.canvas.alt_code, "65"); + alt_frame(&ctx, &mut a, vec![egui::Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "A"); + } + + /// Switching to another window mid-code drops the digits: releasing Alt later types nothing. + #[test] + fn alt_code_is_dropped_when_the_window_loses_focus() { + if cfg!(target_os = "macos") { + return; + } + let (ctx, mut a) = alt_frame_app(); + alt_frame(&ctx, &mut a, vec![alt_key(Key::Num6, true, false), alt_key(Key::Num5, true, false)]); + alt_frame(&ctx, &mut a, vec![egui::Event::WindowFocused(false)]); + assert_eq!(a.canvas.alt_code, ""); + alt_frame(&ctx, &mut a, vec![egui::Event::WindowFocused(true), egui::Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), ""); + } + + /// Windows composes Alt codes with the system's code pages: for a code up to 255 its + /// character replaces ours (Polish Alt+0185 is ą, not ¹); above 255 Windows reduces the code + /// to a byte, so ours stays and its text isn't typed (#961). + #[test] + fn system_composed_alt_code_character_wins_up_to_255() { + let (ctx, mut a) = alt_frame_app(); + let text = |a: &WordApp| a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end().to_string(); + let _ = a.run("text.typeCode", json!({"code": "0185", "ansi": 1252})); + a.canvas.alt_code_echo = Some(AltCodeEcho { typed: "¹".into(), system_composes: true, byte_code: true }); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("ą".into())]); + assert_eq!(text(&a), "ą"); + let _ = a.run("text.typeCode", json!({"code": "8364"})); + a.canvas.alt_code_echo = Some(AltCodeEcho { typed: "€".into(), system_composes: true, byte_code: false }); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("|".into())]); + assert_eq!(text(&a), "ą€"); + // Elsewhere a different text after the code is ordinary typing. + a.canvas.alt_code_echo = Some(AltCodeEcho { typed: "€".into(), system_composes: false, byte_code: false }); + alt_frame(&ctx, &mut a, vec![egui::Event::Text("x".into())]); + assert_eq!(text(&a), "ą€x"); + } } diff --git a/docs/typing-parity.md b/docs/typing-parity.md index c4be6aebe..cc8d17572 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-12 · **Change:** 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-13 · **Change:** minor (Alt+numpad character codes, #961; 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 @@ -71,6 +71,7 @@ Windows/Linux keys are given first, macOS second. | Outline view: Alt+Shift+Plus / Minus, Alt+Shift+1–9, Alt+Shift+A | Expand / Collapse the heading (one level at a time); Show Level 1–9; show all levels. The caret never stays in hidden text: it moves on to the next shown paragraph | `outline_show_level_filters_paragraphs` | | Shift+F5 | Go Back: the caret cycles through the last four places edited, most recent first | `shift_f5_goes_back_through_the_last_edits` | | Alt+X (no macOS default) | the hex code before the caret (up to six digits, `U+` optional) or the selected code becomes its character; otherwise the character before the caret becomes its code (`é` ↔ `00E9`); one undo step | `alt_x_toggles_between_a_character_and_its_code` | +| Alt+numpad code (Windows and Linux; not macOS) | hold Alt, type a decimal code, release Alt: the character appears at the caret as typed text (one undo step). With a leading 0 the code is the ANSI code page — Windows-1252 by default (`0233` → é, `0128` → €); without one, 1–255 is the OEM code page — 437 by default and in US English (`233` → Θ, `1` → ☺, `7` → •), 850 for Western European locales and English outside the US (`155` → ø; `233` is Ú there, per the CP850 mapping file — é is `130` in both). On Windows the system locale picks both pages (`text.typeCode` `ansi`/`oem`): Central European 1250/852 (Polish `0185` and `165` → ą), Cyrillic 1251/866, Greek 1253/737, Turkish 1254/857, and for codes up to 255 the character Windows composes itself replaces ours (it knows every code page, double-byte ones too); Linux keeps 1252 and 437/850. Above 255, with or without the 0, the Unicode character with that decimal value (`8364` → €). Control characters, 0, unassigned bytes and values past Unicode type nothing; auto-repeat of a held digit adds nothing, and switching windows drops a half-typed code. Word counts only keypad digits; egui reports keypad and top-row digits alike, so top-row digits with Alt alone compose codes too. An Alt code never opens KeyTips | `alt_numpad_codes_map_like_word_on_windows`, `alt_numpad_code_types_one_character`, ui-egui `alt_numpad_code_types_its_character_without_keytips` | ## AutoCorrect and AutoFormat as you type @@ -185,6 +186,7 @@ Word. | Date | Change | Summary | |---|---|---| | 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-12 | 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) | | 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) | | 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) | From b3132a0b11f0b667f586b38933c806a0e7fb36fa Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 18:50:16 +0200 Subject: [PATCH 039/199] =?UTF-8?q?Review=20=E2=80=BA=20Language=20reaches?= =?UTF-8?q?=20every=20proofing=20language=20(#860)=20(#1151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #860: Language menu opens Set Proofing Language… and Language Preferences… Review › Language (and the status bar's language menu) offered only four languages since #346 replaced the button that opened the Language dialog, so French and the other 25 languages, and Set As Default, were out of reach. The menu now ends with Set Proofing Language… (the Language dialog) and Language Preferences… (Options › Language); a language outside the short list shows by name and checked, and the status bar names it. Co-Authored-By: Claude Opus 5.5 * #860: other regions of a language show by name, and no language shows twice A tag outside the Language dialog's list (fr-CA, en-AU, de-AT) shows as the language and region (French (Canada)) instead of the raw tag; pt-AO is Portuguese (Angola), not Brazil. A current language named like a short-list entry checks that entry rather than showing again above the list. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/ribbon.rs | 218 +++++++++++++++++++++++++--- docs/localization-parity.md | 5 +- docs/ui-parity.md | 5 +- 22 files changed, 247 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f96842a2b..2b9fc164e 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-13 · **Change:** trivial (progress log: interface audit back in CI, #1128; previously trivial: progress log: Alt+numpad character codes, #961; 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-13 · **Change:** trivial (progress log: interface audit back in CI, #1128; previously trivial: progress log: Set Proofing Language… in the Language menu, #860; 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. @@ -150,6 +150,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | | 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | +| 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -299,6 +300,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | | 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | +| 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index c2d5b0ddc..7e006c20f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2488,3 +2488,5 @@ Go to what: Отиване до: Type +2 or -1 to move from the page you are on. Въведете +2 или -1, за да се преместите спрямо текущата страница. Suggested Предложения + Set Proofing Language… Задаване на езика за проверка… + Language Preferences… Езикови предпочитания… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eaf7e00be..56e6bbe1c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2113,3 +2113,5 @@ Go to what: Přejít na: Type +2 or -1 to move from the page you are on. Zadáním +2 nebo -1 se posunete od aktuální stránky. Suggested Navrhované + Set Proofing Language… Nastavit jazyk kontroly pravopisu… + Language Preferences… Předvolby jazyka… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 512788019..f2eeb8424 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2107,3 +2107,5 @@ Go to what: Gehe zu: Type +2 or -1 to move from the page you are on. Geben Sie +2 oder -1 ein, um von der aktuellen Seite aus zu springen. Suggested Vorschläge + Set Proofing Language… Sprache für die Korrekturhilfen festlegen… + Language Preferences… Spracheinstellungen… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1ef9ae0ef..7b01800ee 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2474,3 +2474,5 @@ preview Title Título Go to what: Ir a: Type +2 or -1 to move from the page you are on. Escribe +2 o -1 para moverte desde la página actual. Suggested Sugerencias + Set Proofing Language… Establecer idioma de revisión… + Language Preferences… Preferencias de idioma… diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index a810e771c..f6d794c19 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2142,3 +2142,5 @@ Go to what: Mine: Type +2 or -1 to move from the page you are on. Sisesta +2 või -1, et liikuda praegusest leheküljest edasi või tagasi. Suggested Soovitused + Set Proofing Language… Määra õigekeelsuskontrolli keel… + Language Preferences… Keele-eelistused… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 39ab205c6..145e960ad 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2116,3 +2116,5 @@ Go to what: Atteindre : Type +2 or -1 to move from the page you are on. Tapez +2 ou -1 pour vous déplacer à partir de la page actuelle. Suggested Suggestions + Set Proofing Language… Définir la langue de vérification… + Language Preferences… Préférences linguistiques… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cd5e6b347..742b97865 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2497,3 +2497,5 @@ Go to what: Ugrás ide: Type +2 or -1 to move from the page you are on. Írd be: +2 vagy -1, ha az aktuális oldalhoz képest szeretnél lépni. Suggested Javaslatok + Set Proofing Language… Nyelvi ellenőrzés nyelvének beállítása… + Language Preferences… Nyelvi beállítások… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 927212063..6c007b9a7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2483,3 +2483,5 @@ preview Title Esempio Go to what: Vai a: Type +2 or -1 to move from the page you are on. Digita +2 o -1 per spostarti dalla pagina corrente. Suggested Suggerimenti + Set Proofing Language… Imposta lingua di correzione… + Language Preferences… Preferenze lingua… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a2d3d4a99..1e0c92c82 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2108,3 +2108,5 @@ Go to what: 移動先: Type +2 or -1 to move from the page you are on. 現在のページから移動するには +2 や -1 のように入力します。 Suggested おすすめ + Set Proofing Language… 校正言語の設定… + Language Preferences… 言語の設定… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 93364ea45..b38dc1041 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2492,3 +2492,5 @@ preview Title Tytuł Go to what: Przejdź do: Type +2 or -1 to move from the page you are on. Wpisz +2 lub -1, aby przejść o tyle stron od bieżącej. Suggested Sugerowane + Set Proofing Language… Ustaw język sprawdzania… + Language Preferences… Preferencje językowe… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5b49b3dcb..856f29477 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2120,3 +2120,5 @@ Go to what: Ir para: Type +2 or -1 to move from the page you are on. Digite +2 ou -1 para se mover a partir da página atual. Suggested Sugestões + Set Proofing Language… Definir idioma de revisão… + Language Preferences… Preferências de idioma… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e21c6ffc4..c8a6ab830 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2498,3 +2498,5 @@ preview Title Пример Go to what: Перейти к: Type +2 or -1 to move from the page you are on. Введите +2 или -1, чтобы перейти на столько страниц от текущей. Suggested Предлагаемые + Set Proofing Language… Выбрать язык проверки правописания… + Language Preferences… Языковые параметры… diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index c0296b5f1..c5133e012 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2118,3 +2118,5 @@ Go to what: Idi na: Type +2 or -1 to move from the page you are on. Unesite +2 ili -1 da biste se pomerili od strane na kojoj ste. Suggested Predlozi + Set Proofing Language… Postavi jezik provere… + Language Preferences… Jezičke postavke… diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b4d69d54d..f87bae9ac 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2122,3 +2122,5 @@ Go to what: Иди на: Type +2 or -1 to move from the page you are on. Унесите +2 или -1 да бисте се померили од стране на којој сте. Suggested Предлози + Set Proofing Language… Постави језик провере… + Language Preferences… Језичке поставке… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 349f2675a..8567dd0d4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2497,3 +2497,5 @@ preview Title Örnek Go to what: Gidilecek yer: Type +2 or -1 to move from the page you are on. Bulunduğunuz sayfadan ilerlemek için +2, geri gitmek için -1 yazın. Suggested Önerilenler + Set Proofing Language… Yazım denetimi dilini ayarla… + Language Preferences… Dil tercihleri… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d9c933317..4907f8fc7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2120,3 +2120,5 @@ Go to what: Перейти до: Type +2 or -1 to move from the page you are on. Введіть +2 або -1, щоб перейти на стільки сторінок від поточної. Suggested Пропоновані + Set Proofing Language… Вибрати мову перевірки правопису… + Language Preferences… Мовні параметри… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3c42b08dc..75408eb47 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2120,3 +2120,5 @@ Go to what: 定位目标: Type +2 or -1 to move from the page you are on. 输入 +2 或 -1 可从当前页向后或向前移动。 Suggested 建议 + Set Proofing Language… 设置校对语言… + Language Preferences… 语言首选项… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ad9236bbb..ba636a44c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2109,3 +2109,5 @@ Go to what: 移至目標: Type +2 or -1 to move from the page you are on. 輸入 +2 或 -1 可從目前頁面向後或向前移動。 Suggested 建議 + Set Proofing Language… 設定校訂語言… + Language Preferences… 語言喜好設定… diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 8f8e76307..c9a413189 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -429,45 +429,175 @@ pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 6] = [ ]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's -/// default, US English; a tag without a name here: the tag). +/// default, US English): a listed language by its name; another region of one by the language +/// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag. pub(crate) fn language_name(tag: Option<&str>) -> String { let tag = tag.unwrap_or("en-US"); - if let Some((_, name)) = PROOFING_LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)) { - return (*name).to_string(); + let named = + |t: &str| PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n); + // Any language the Language dialog offers (French, German…) by its name, not its tag (#860). + if let Some(name) = named(tag) { + return name.to_string(); } - match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { - wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), - wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(), - wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), - wordcraft_engine::proof::ProofLang::It => "Italian".into(), - wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), - _ => tag.to_string(), + let mut parts = tag.split(['-', '_']); + let primary = parts.next().unwrap_or(""); + // The region: the first two-letter or three-digit subtag after the language (and script). + let region = + parts.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit()))); + match region { + // A region the list doesn't have: the language's name without its own region or variant. + Some(r) => { + let language = crate::dialogs_layout::LANGUAGES + .iter() + .find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary))) + .map(|(_, n)| n.split(" (").next().unwrap_or(n)); + match language { + Some(language) => format!("{language} ({})", region_name(r)), + None => tag.to_string(), + } + } + // A bare language subtag ("pt", "pl"): the variant WordCraft proofs. + None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { + wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), + wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(), + wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), + wordcraft_engine::proof::ProofLang::It => "Italian".into(), + wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), + _ => tag.to_string(), + }, } } +/// The English name of a region subtag the language list has no entry for (else the subtag). +fn region_name(code: &str) -> String { + const REGIONS: [(&str, &str); 62] = [ + ("AE", "U.A.E."), + ("AO", "Angola"), + ("AR", "Argentina"), + ("AT", "Austria"), + ("AU", "Australia"), + ("BA", "Bosnia and Herzegovina"), + ("BE", "Belgium"), + ("BO", "Bolivia"), + ("BR", "Brazil"), + ("BY", "Belarus"), + ("CA", "Canada"), + ("CH", "Switzerland"), + ("CL", "Chile"), + ("CN", "China"), + ("CO", "Colombia"), + ("CR", "Costa Rica"), + ("CV", "Cabo Verde"), + ("CY", "Cyprus"), + ("DE", "Germany"), + ("DO", "Dominican Republic"), + ("EC", "Ecuador"), + ("EG", "Egypt"), + ("ES", "Spain"), + ("FR", "France"), + ("GB", "United Kingdom"), + ("GT", "Guatemala"), + ("HK", "Hong Kong SAR"), + ("IE", "Ireland"), + ("IN", "India"), + ("IQ", "Iraq"), + ("IT", "Italy"), + ("JM", "Jamaica"), + ("JO", "Jordan"), + ("KW", "Kuwait"), + ("KZ", "Kazakhstan"), + ("LB", "Lebanon"), + ("LI", "Liechtenstein"), + ("LU", "Luxembourg"), + ("MA", "Morocco"), + ("MC", "Monaco"), + ("ME", "Montenegro"), + ("MO", "Macao SAR"), + ("MX", "Mexico"), + ("MY", "Malaysia"), + ("MZ", "Mozambique"), + ("NL", "Netherlands"), + ("NZ", "New Zealand"), + ("PE", "Peru"), + ("PH", "Philippines"), + ("PR", "Puerto Rico"), + ("PT", "Portugal"), + ("PY", "Paraguay"), + ("QA", "Qatar"), + ("RU", "Russia"), + ("SG", "Singapore"), + ("SY", "Syria"), + ("TW", "Taiwan"), + ("UA", "Ukraine"), + ("US", "United States"), + ("UY", "Uruguay"), + ("VE", "Venezuela"), + ("ZA", "South Africa"), + ]; + REGIONS.iter().find(|(c, _)| c.eq_ignore_ascii_case(code)).map_or_else(|| code.to_ascii_uppercase(), |(_, n)| (*n).to_string()) +} + /// The language at the caret (resolved through the styles), translated, for the status bar. pub(crate) fn caret_language(app: &WordApp) -> String { + tl!(&language_name(caret_lang_tag(app).as_deref())).to_string() +} + +/// The language tag at the caret, resolved through the styles and the document default. +fn caret_lang_tag(app: &WordApp) -> Option { let s = &app.session; let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()); - let rc = s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()); - tl!(&language_name(rc.lang.as_deref())).to_string() + s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()).lang } /// Review › Language (and the status bar's language): set the selection's proofing language, or -/// leave it unchecked. The current choice has a check mark. +/// leave it unchecked. The current choice has a check mark; a language outside the short list +/// (French, German…) shows checked above it. Set Proofing Language… opens the Language dialog +/// with every language and Set As Default, and Language Preferences… opens Options › Language +/// (#860: the short list alone left no way to pick French). pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { let st = app.session.run("format.state", &json!({})).unwrap_or_default(); - let current = st.get("lang").and_then(Value::as_str).unwrap_or("").to_string(); + // The selection's own language; else (nothing set on the text) the one it gets from its + // style or the document default, as the status bar shows. + let current = match st.get("lang") { + Some(Value::String(t)) => t.clone(), + Some(Value::Null) if app.session.sel.is_collapsed() => caret_lang_tag(app).unwrap_or_else(|| "en-US".into()), + _ => String::new(), + }; let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false); - for (tag, name) in PROOFING_LANGUAGES { - let on = current.eq_ignore_ascii_case(tag) + let listed = |tag: &str| { + current.eq_ignore_ascii_case(tag) || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")) || (tag == "pt-BR" && current.eq_ignore_ascii_case("pt")) - || (tag == "it-IT" && current.eq_ignore_ascii_case("it")); + || (tag == "it-IT" && current.eq_ignore_ascii_case("it")) + || (tag == "en-US" && current.eq_ignore_ascii_case("en")) + }; + let checks = language_checks(¤t, listed); + if let Some(name) = checks.extra { + mi_check(ui, app, &name, true, "review.language", json!({"lang": current})); + } + for ((tag, name), on) in PROOFING_LANGUAGES.iter().zip(checks.listed) { mi_check(ui, app, name, on, "review.language", json!({"lang": tag})); } ui.separator(); mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof})); + ui.separator(); + mi_check(ui, app, "Set Proofing Language…", false, "review.language", json!({})); + mi_check(ui, app, "Language Preferences…", false, "file.options", json!({"pane": "language"})); +} + +/// The Language menu's check marks for the `current` tag: which short-list entries are checked +/// (`listed` matches a tag), and the name of the current language when it is none of them, +/// shown checked above. A language named like a short-list entry checks that entry, so no name +/// shows twice. +struct LanguageChecks { + extra: Option, + listed: [bool; PROOFING_LANGUAGES.len()], +} + +fn language_checks(current: &str, listed: impl Fn(&str) -> bool) -> LanguageChecks { + let name = (!current.is_empty()).then(|| language_name(Some(current))); + let on = PROOFING_LANGUAGES.map(|(t, n)| listed(t) || name.as_deref() == Some(n)); + LanguageChecks { extra: name.filter(|_| !on.contains(&true)), listed: on } } /// A menu item with a check mark when `checked` (the current choice of several). @@ -2468,6 +2598,30 @@ mod tests { } } + /// #860 review: a tag the language list doesn't have shows by language and region, not as + /// the tag; Portuguese (Angola) isn't Brazil's, and no name shows twice in the menu. + #[test] + fn other_regions_show_by_name_once() { + assert_eq!(language_name(Some("fr-CA")), "French (Canada)"); + assert_eq!(language_name(Some("en-AU")), "English (Australia)"); + assert_eq!(language_name(Some("de_AT")), "German (Austria)"); + assert_eq!(language_name(Some("pt-AO")), "Portuguese (Angola)"); + assert_eq!(language_name(Some("sr-Latn-BA")), "Serbian (Bosnia and Herzegovina)"); + assert_eq!(language_name(Some("en-ZZ")), "English (ZZ)"); + assert_eq!(language_name(Some("xx-YY")), "xx-YY"); + let exact = |cur: &'static str| move |t: &str| t.eq_ignore_ascii_case(cur); + for tag in ["fr-CA", "pt-AO", "en-AU"] { + let c = language_checks(tag, exact(tag)); + let name = c.extra.expect("shown above the list"); + assert!(!PROOFING_LANGUAGES.iter().any(|(_, n)| *n == name), "{name} is listed once"); + assert!(!c.listed.contains(&true), "{tag}: nothing in the list checked"); + } + // A tag named like a list entry checks that entry instead of showing it again. + let c = language_checks("PT-br-x-test", |_| false); + assert_eq!(c.extra, None); + assert_eq!(c.listed, PROOFING_LANGUAGES.map(|(t, _)| t == "pt-BR")); + } + #[test] fn portuguese_has_a_name_in_the_language_menu() { assert!(PROOFING_LANGUAGES.iter().any(|(t, _)| *t == "pt-BR")); @@ -2580,6 +2734,36 @@ mod tests { assert!(tall.is_empty(), "groups taller than the ribbon: {tall:#?}"); } + /// #860: Review › Language (and the status bar) reach every proofing language, not just the + /// short list: Set Proofing Language… opens the Language dialog (French, Set As Default), + /// Language Preferences… opens Options › Language, and French text shows by name, checked. + #[test] + fn language_menu_reaches_every_language() { + use egui_kittest::kittest::{By, NodeT, Queryable}; + let item = |label: &'static str| By::new().label_contains(label); + let app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Bonjour")), crate::Services::default()); + let mut h = + egui_kittest::Harness::builder().with_size(vec2(400.0, 400.0)).build_ui_state(|ui, app: &mut WordApp| language_menu(ui, app), app); + h.run(); + assert!(h.query(item("French (France)")).is_none(), "the short list only, on an English document"); + assert!(!h.get(item("Set Proofing Language…")).accesskit_node().is_disabled(), "never greyed out on a fresh document"); + assert!(!h.get(item("English (United States)")).accesskit_node().is_disabled()); + h.get(item("Set Proofing Language…")).click(); + h.run(); + assert_eq!(h.state().dialog.as_ref().map(crate::dialogs::Dialog::name), Some("language")); + h.state_mut().dialog = None; + // French for the whole document: the menu and the status bar name it. + h.state_mut().run("review.language", json!({"lang": "fr-FR", "default": true})).unwrap(); + h.run(); + assert_eq!(caret_language(h.state()), "French (France)"); + assert!(h.query(item("French (France)")).is_some(), "the current language is listed (only while it is the current one)"); + h.get(item("Language Preferences…")).click(); + h.run(); + assert!(h.state().ui.backstage, "File › Options opened"); + assert_eq!(h.state().ui.options_pane, "language"); + assert_eq!(language_name(Some("de-DE")), "German (Germany)"); + } + #[test] fn resolve_tab_falls_back_when_contextual_tab_expires() { let all = ["Home", "Table Design", "Picture Format"]; diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 4fec80d7b..d3c0a7944 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -30,6 +30,8 @@ native-speaker review (human). language: the true share of visible strings is **~93–95%** (estimated). - Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). - **Proofing: English, Polish, Portuguese and Italian**, chosen per run from its language (`w:lang`, resolved through + the styles; Review › Language and the status bar set it: a short list, plus Set Proofing + Language… for any of 29 languages and Set As Default for the document). Languages without proofing tools get the styles; Review › Language and the status bar set it). Languages without proofing tools get no spelling or grammar marks (as in Word without the language's tools) instead of being flagged as English. Detail in [Proofing per language](#proofing-per-language). @@ -174,6 +176,7 @@ WordCraft keeps 2,54 cm). | 2026-10-12 | trivial | Region conventions: File › Options › Advanced › Region (saved) and an Automatic units entry; Unix `LC_PAPER`/`LC_MEASUREMENT`/`LC_NUMERIC` each decide their own convention; the web reads the browser's locale; `WORDCRAFT_NO_PREFS` runs stay en-US; where the decimal separator comes from (#838 review) | | 2026-10-12 | minor | New blank documents follow the system region: A4 outside the Letter territories, centimetres outside the US, the region's decimal separator in length fields; explicit Measurement units choice wins (#838) | | 2026-10-11 | minor | Czech (`cs`) interface catalog added | +| 2026-10-13 | trivial | Review › Language and the status bar menu open Set Proofing Language… (29 languages, Set As Default) and Language Preferences…; French or German text shows by name (#860) | | 2026-10-12 | minor | Portuguese proofing: pt-BR spelling from the installed VERO dictionary (the affix engine grew `FLAG UTF-8`, 128 flag bits, continuation classes and `FORBIDDENWORD`), accent-first suggestions, conservative grammar with Portuguese messages, TeX Portuguese hyphenation patterns (BSD 3-clause); Review › Language offers Português (Brasil); a pt-br interface writes pt-BR documents; dimension ~27% → ~28% | | 2026-10-11 | minor | Polish interface (13 languages) and Polish proofing: SJP.PL spelling with suggestions, Polish grammar rules, TeX Polish hyphenation; proofing language per run; dimension ~25% → ~27% | | 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0e5811954..8f3afc676 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-13 · **Change:** trivial (double-click the margin to create and edit a header or footer, #960; 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-13 · **Change:** trivial (double-click the margin to create and edit a header or footer, #960; previously trivial: Review › Language and the status bar's language menu open Set Proofing Language… and Language Preferences…, #860; 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 @@ -33,7 +33,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | 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 | 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; a click sets the proofing language) | ✅ | 85% | — | +| 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) | ✅ | ✅ | 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% | — | @@ -126,6 +126,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) | | 2026-10-11 | trivial | Insert › Icons picker: search, category chips, icons drawn live from the library's path data; Picture Format › Icon Color (#309) | | 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) | +| 2026-10-13 | trivial | Review › Language and the status bar's language menu end with Set Proofing Language… (the Language dialog: every language, Set As Default) and Language Preferences…; a language outside the short list (French…) shows by name and checked, another region of a listed language by language and region (French (Canada)) (#860) | | 2026-10-10 | minor | Status bar language: the caret's language, a click opens the proofing-language menu (also Review › Language); context menu items translated | | 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) | | 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed | From 48f1b6b842e5a50ff4d88ac6283e87c8ce563677 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:50:29 +0200 Subject: [PATCH 040/199] Dark theme: the ruler text area is lighter than its margins (#1154) --- crates/ui-egui/src/theme.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 441ff2ad9..9c8ff4aab 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -336,9 +336,10 @@ impl Tokens { input: Color32::from_rgb(0x1E, 0x1E, 0x1E), input_border: Color32::from_rgb(0x7A, 0x7A, 0x7A), group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0), - ruler: Color32::from_rgb(0x2E, 0x2E, 0x2E), - ruler_margin: Color32::from_rgb(0x45, 0x45, 0x45), - ruler_tick: Color32::from_rgb(0xA0, 0xA0, 0xA0), + // The text area is lighter than the margins, as in the light theme (#1148). + ruler: Color32::from_rgb(0x45, 0x45, 0x45), + ruler_margin: Color32::from_rgb(0x2E, 0x2E, 0x2E), + ruler_tick: Color32::from_rgb(0xB4, 0xB4, 0xB4), status_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F), selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55), find_highlight: Color32::from_rgba_unmultiplied(0xC8, 0xA8, 0x00, 0x70), @@ -709,6 +710,18 @@ mod tests { } } + /// #1148: on both rulers the text area is lighter than the margins in both themes, as in the + /// light theme, and the numbers drawn on it still reach 4.5:1. + #[test] + fn ruler_text_area_is_lighter_than_its_margins() { + let lum = |c: egui::Color32| contrast(c, egui::Color32::BLACK); + for t in [super::Tokens::light(), super::Tokens::dark()] { + assert!(lum(t.ruler) > lum(t.ruler_margin), "dark={}: ruler text area is darker than its margins", t.dark); + let r = contrast(t.ruler_tick, t.ruler); + assert!(r >= 4.5, "dark={}: ruler numbers on the text area are {r:.2}:1", t.dark); + } + } + /// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the /// interface fonts were used. Faces found for the uncovered characters are added as fallbacks, /// once, and not for characters the interface fonts draw. From 3f5d8feebe7336c6692f1a93ffc23398301d476a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:50:40 +0200 Subject: [PATCH 041/199] Print: the preview fills the pane, with a zoom bar and scrolling both ways (#1157) --- crates/ui-egui/src/backstage.rs | 5 + crates/ui-egui/src/lib.rs | 47 ++++++ crates/ui-egui/src/print_page.rs | 244 +++++++++++++++++++------------ 3 files changed, 200 insertions(+), 96 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 28dc72fe0..ee3b2ac86 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -129,6 +129,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); }); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| { + // The Print page fills the pane and scrolls its settings and its preview itself (#1156). + if app.ui.backstage_page == "print" { + export_page(app, ui); + return; + } egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() { "home" => home_page(app, ui), "new" => new_page(app, ui), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 86ac1bdd4..b140565be 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3288,6 +3288,53 @@ mod tests { } } + /// #1156: the Print page's preview fills the pane beside the settings, zooms from its own + /// bar, scrolls both ways once the page outgrows the pane, and One Page fits it again. + #[test] + fn the_print_preview_fills_the_pane_zooms_and_scrolls_both_ways() { + use egui::accesskit::Role; + use egui_kittest::kittest::{By, Queryable}; + let mut a = app(); + a.run("file.print", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + let page = |h: &egui_kittest::Harness<'_, WordApp>| h.get(By::new().role(Role::Image)).rect(); + let fit = page(&h); + assert!(fit.height() > 650.0, "the page fills the pane's height: {fit:?}"); + assert!(h.query(By::new().role(Role::Label).label("Page 1 of 1")).is_some(), "the page indicator stays"); + + for _ in 0..5 { + h.get_by_label("Zoom In").click(); + h.run_steps(2); + } + let zoomed = page(&h); + assert!(zoomed.width() > fit.width() * 1.4 && zoomed.height() > 900.0, "zoomed in: {zoomed:?}"); + + h.input_mut().events.push(egui::Event::PointerMoved(zoomed.center())); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(-120.0, -120.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(20); + let moved = page(&h); + assert!(moved.min.x < zoomed.min.x - 50.0, "scrolls sideways: {zoomed:?} -> {moved:?}"); + assert!(moved.min.y < zoomed.min.y - 50.0, "scrolls down: {zoomed:?} -> {moved:?}"); + + h.get_by_label("One Page").click(); + h.run_steps(4); + let again = page(&h); + assert!((again.height() - fit.height()).abs() < 1.0, "One Page fits the page again: {fit:?} vs {again:?}"); + } + /// File › Options shows Make WordCraft the default… only when the host can do it (#295), and /// the button runs the host's hook. #[test] diff --git a/crates/ui-egui/src/print_page.rs b/crates/ui-egui/src/print_page.rs index 441c6b567..4cbd70260 100644 --- a/crates/ui-egui/src/print_page.rs +++ b/crates/ui-egui/src/print_page.rs @@ -25,11 +25,13 @@ pub struct PrintSettings { pub collate: bool, /// The page shown in the preview (0-based). pub preview: usize, + /// The preview's zoom (1.0 is the page's real size), or `None` to fit the whole page. + pub zoom: Option, } impl Default for PrintSettings { fn default() -> Self { - PrintSettings { range: "all".into(), pages: String::new(), parity: "all".into(), copies: 1, collate: true, preview: 0 } + PrintSettings { range: "all".into(), pages: String::new(), parity: "all".into(), copies: 1, collate: true, preview: 0, zoom: None } } } @@ -86,7 +88,9 @@ fn page_texture(app: &WordApp, ctx: &egui::Context, lay: &DocLayout, page: usize return Some(t); } let pg = lay.pages.get(page)?; - let scale = (width / pg.w.max(1.0) * ctx.pixels_per_point()).clamp(0.05, 4.0); + // Zoomed in, the texture stays within what the GPU takes and is stretched beyond that. + let max_side = ctx.input(|i| i.max_texture_side) as f32 / pg.w.max(pg.h).max(1.0); + let scale = (width / pg.w.max(1.0) * ctx.pixels_per_point()).clamp(0.05, 4.0).min(max_side); let img = wordcraft_render::render_page(&app.session.doc, pg, scale, &Default::default()); let color = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight()); let tex = ctx.load_texture("print_page_preview", color, egui::TextureOptions::LINEAR); @@ -106,115 +110,142 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.set_width(380.0); - ui.spacing_mut().item_spacing.y = 8.0; - let native = app.services.print_native.is_some(); - let hook = native || app.services.print.is_some(); - if native { - if let Some(discover) = &app.services.printers { - crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned()); - } - app.ui.print.copies = app.printing.settings.copies; - } else if hook { - ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below.")); - ui.label( - egui::RichText::new(tl!("The printer, two-sided printing and pages per sheet are chosen in that dialog.")).color(t.text_dim), - ); - } else { - ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer.")); - } - ui.add_space(4.0); - if hook - && ui - .add_enabled( - !native || app.printing.ready(), - egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)), - ) - .clicked() - { - let params = app.ui.print.params(); - let _ = app.run("ui.print", params); - } - if ui - .add(egui::Button::new(egui::RichText::new(tl!("Save these pages as PDF…")).font(medium(13.5))).min_size(vec2(320.0, 34.0))) - .clicked() - { - app.print_pdf_dialog(); - } - ui.add_space(6.0); - - if native { - if app.ui.print.copies > 1 { - ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies"))); - } - } else { - ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0))); - ui.horizontal(|ui| { - let mut n = app.ui.print.copies; - ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2)); - app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES); - if app.ui.print.copies > 1 { - ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies")); + // The settings scroll on their own: the preview beside them keeps the whole height (#1156). + egui::ScrollArea::vertical().id_salt("print_settings").show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 8.0; + let native = app.services.print_native.is_some(); + let hook = native || app.services.print.is_some(); + if native { + if let Some(discover) = &app.services.printers { + crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned()); } - }); - } - - ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0))); - if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) { - // Show the first page that will print. - app.ui.print.preview = if app.ui.print.range == "all" { 0 } else { current.0.min(total.saturating_sub(1)) }; - } - if app.ui.print.range == "custom" { - ui.add( - egui::TextEdit::singleline(&mut app.ui.print.pages) - .hint_text(tl!("Pages like 1-3, 5")) - .desired_width(260.0) - .char_limit(wordcraft_engine::print::MAX_SPEC_CHARS), - ); - } - choice(ui, "print_parity", &mut app.ui.print.parity, &PARITIES); - - match wordcraft_engine::print::plan(&app.ui.print.params(), total, current) { - Ok(plan) => { - let msg = crate::i18n::fmt( - tl!("{count} of {total} pages will print"), - &[("count", &plan.chosen.to_string()), ("total", &total.to_string())], + app.ui.print.copies = app.printing.settings.copies; + } else if hook { + ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below.")); + ui.label( + egui::RichText::new(tl!("The printer, two-sided printing and pages per sheet are chosen in that dialog.")).color(t.text_dim), ); - ui.label(egui::RichText::new(msg).color(t.text_dim)); + } else { + ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer.")); } - Err(e) => { - ui.label(egui::RichText::new(e.to_string()).color(t.red)); + ui.add_space(4.0); + if hook + && ui + .add_enabled( + !native || app.printing.ready(), + egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)), + ) + .clicked() + { + let params = app.ui.print.params(); + let _ = app.run("ui.print", params); } - } - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - if hook { - ui.label(tl!("Or save a copy in another format:")); - } - export_formats(app, ui); + if ui + .add(egui::Button::new(egui::RichText::new(tl!("Save these pages as PDF…")).font(medium(13.5))).min_size(vec2(320.0, 34.0))) + .clicked() + { + app.print_pdf_dialog(); + } + ui.add_space(6.0); + + if native { + if app.ui.print.copies > 1 { + ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies"))); + } + } else { + ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0))); + ui.horizontal(|ui| { + let mut n = app.ui.print.copies; + ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2)); + app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES); + if app.ui.print.copies > 1 { + ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies")); + } + }); + } + + ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0))); + if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) { + // Show the first page that will print. + app.ui.print.preview = if app.ui.print.range == "all" { 0 } else { current.0.min(total.saturating_sub(1)) }; + } + if app.ui.print.range == "custom" { + ui.add( + egui::TextEdit::singleline(&mut app.ui.print.pages) + .hint_text(tl!("Pages like 1-3, 5")) + .desired_width(260.0) + .char_limit(wordcraft_engine::print::MAX_SPEC_CHARS), + ); + } + choice(ui, "print_parity", &mut app.ui.print.parity, &PARITIES); + + match wordcraft_engine::print::plan(&app.ui.print.params(), total, current) { + Ok(plan) => { + let msg = crate::i18n::fmt( + tl!("{count} of {total} pages will print"), + &[("count", &plan.chosen.to_string()), ("total", &total.to_string())], + ); + ui.label(egui::RichText::new(msg).color(t.text_dim)); + } + Err(e) => { + ui.label(egui::RichText::new(e.to_string()).color(t.red)); + } + } + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + if hook { + ui.label(tl!("Or save a copy in another format:")); + } + export_formats(app, ui); + }); }); ui.add_space(24.0); ui.vertical(|ui| preview(app, ui, &lay, total)); }); } -/// The preview: one page with Previous/Next. +/// Room around the page inside the preview pane. +const PREVIEW_MARGIN: f32 = 10.0; +/// The preview's bottom bar: the page indicator on the left, the zoom on the right. +const PREVIEW_BAR: f32 = 34.0; + +/// The next preview zoom one step (10%) in or out, from 10% to 500% as in the status bar. +fn zoom_step(z: f32, up: bool) -> f32 { + let z = (z * 10.0).round() / 10.0; + (if up { z + 0.1 } else { z - 0.1 }).clamp(0.1, 5.0) +} + +/// The preview (#1156): the page fills the pane, a bar below pages through the document and +/// zooms; zoomed in, the page scrolls both ways. fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { let t = Tokens::get(ui.ctx()); let ctx = ui.ctx().clone(); - let width = 340.0; let page = app.ui.print.preview; - let aspect = lay.pages.get(page).map_or(11.0 / 8.5, |p| p.h.max(1.0) / p.w.max(1.0)).clamp(0.2, 5.0); - let (r, _) = ui.allocate_exact_size(vec2(width, width * aspect), Sense::hover()); - ui.painter().rect_filled(r.translate(vec2(2.0, 3.0)), 0.0, t.page_shadow); - ui.painter().rect(r, 0.0, egui::Color32::WHITE, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); - if let Some(tex) = page_texture(app, &ctx, lay, page, width) { - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); - } - ui.add_space(6.0); + let (pw, ph) = lay.pages.get(page).map_or((612.0, 792.0), |p| (p.w.max(1.0), p.h.max(1.0))); + let pane = vec2(ui.available_width().max(120.0), (ui.available_height() - PREVIEW_BAR).max(120.0)); + // Screen points per page point. + let fit = ((pane.x - 2.0 * PREVIEW_MARGIN) / pw).min((pane.y - 2.0 * PREVIEW_MARGIN) / ph).max(0.01); + let scale = app.ui.print.zoom.map_or(fit, |z| z * crate::canvas::PX_PER_PT); + let size = vec2(pw * scale, ph * scale); + let label = crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &(page + 1).to_string()), ("pages", &total.to_string())]); + egui::ScrollArea::both().id_salt("print_preview").auto_shrink(false).max_width(pane.x).max_height(pane.y).show(ui, |ui| { + let room = vec2((size.x + 2.0 * PREVIEW_MARGIN).max(ui.available_width()), (size.y + 2.0 * PREVIEW_MARGIN).max(ui.available_height())); + let (area, _) = ui.allocate_exact_size(room, Sense::hover()); + let r = Rect::from_center_size(area.center(), size); + let resp = ui.interact(r, ui.id().with("print_preview_page"), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, &label)); + ui.painter().rect_filled(r.translate(vec2(2.0, 3.0)), 0.0, t.page_shadow); + ui.painter().rect(r, 0.0, egui::Color32::WHITE, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); + if let Some(tex) = page_texture(app, &ctx, lay, page, size.x) { + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + } + }); ui.horizontal(|ui| { + ui.set_height(PREVIEW_BAR); let nav = |ui: &mut Ui, icon: &str, tip: &str, enabled: bool| { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); let col = if enabled { t.icon } else { t.text_disabled }; if enabled && resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); @@ -225,11 +256,32 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { if nav(ui, "chevronLeft", tl!("Previous Page"), page > 0) { app.ui.print.preview = page - 1; } - let label = crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &(page + 1).to_string()), ("pages", &total.to_string())]); - ui.label(egui::RichText::new(label).font(regular(13.0))); + ui.label(egui::RichText::new(&label).font(regular(13.0))); if nav(ui, "chevronRight", tl!("Next Page"), page + 1 < total) { app.ui.print.preview = page + 1; } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let z = scale / crate::canvas::PX_PER_PT; + if nav(ui, "onePage", tl!("One Page"), true) { + app.ui.print.zoom = None; + } + ui.label(egui::RichText::new(format!("{}%", (z * 100.0).round())).font(regular(13.0))); + if nav(ui, "plus", tl!("Zoom In"), z < 5.0) { + app.ui.print.zoom = Some(zoom_step(z, true)); + } + // A narrow window shortens the slider, then drops it, before it covers the page indicator. + let room = ui.available_width() - 26.0 - 2.0 * ui.spacing().item_spacing.x - 12.0; + if room >= 40.0 { + let mut v = z; + ui.spacing_mut().slider_width = room.min(110.0); + if ui.add(egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false)).on_hover_text(tl!("Zoom")).changed() { + app.ui.print.zoom = Some(v.clamp(0.1, 5.0)); + } + } + if nav(ui, "minus", tl!("Zoom Out"), z > 0.1) { + app.ui.print.zoom = Some(zoom_step(z, false)); + } + }); }); } From 7d3767ba99849fbfe3bd8672637afc9dcf18d784 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:51:00 +0200 Subject: [PATCH 042/199] Zotero: text typed just after a citation goes outside its field (#1162) Co-authored-by: Brandon Thomas --- crates/doc/src/fields.rs | 12 ++++++++++++ crates/engine/src/cmd/mod.rs | 3 +++ crates/engine/src/tests_typing.rs | 27 +++++++++++++++++++++++++++ crates/zotero/src/bridge.rs | 5 ++++- crates/zotero/src/tests.rs | 18 ++++++++++++++++++ docs/typing-parity.md | 6 +++++- 6 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/fields.rs b/crates/doc/src/fields.rs index ed142303f..5f0d8479b 100644 --- a/crates/doc/src/fields.rs +++ b/crates/doc/src/fields.rs @@ -93,6 +93,18 @@ impl Document { self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default() } + /// `p` moved past the range-field ends right after it. A click just after a field's result + /// lands before its end marker (the marker takes no room); typing or inserting there belongs + /// after the field, as in Word, not in its result. + pub fn past_field_ends(&self, p: &Pos) -> Pos { + let Some(para) = self.para_at(p) else { return p.clone() }; + let mut off = p.off; + while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) { + off += OBJ.len_utf8(); + } + Pos { off, ..p.clone() } + } + /// Whether any story holds a range-field marker without its partner. pub fn has_unbalanced_field_ranges(&self) -> bool { self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty())) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 33dffa579..41671759e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -267,6 +267,9 @@ pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> { fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> { let mut props = s.typing_props(); + if s.sel.is_collapsed() { + s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus)); + } // Content controls: a placeholder is replaced, locked ones refuse. if let Some(p) = controls::prepare_edit(s)? { props = p; diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 1a1f5014f..f7a262824 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1015,3 +1015,30 @@ fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() { let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX); assert!(x < 300.0, "left-to-right paragraph: caret at {x}"); } + +/// #898: a click just after a citation at the end of a paragraph puts the caret before the +/// citation's hidden field end; what is typed there goes after the citation, as in Word, instead +/// of into its result (Zotero then reports the citation as edited by hand). +#[test] +fn typing_after_a_citation_goes_outside_its_field() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{InlineObject, Path}; + let mut doc = wordcraft_doc::Document::from_text("Cats purr "); + let c = CharProps::default(); + let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + let start = p.len(); + p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false }, &c).unwrap(); + let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); + p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); + let mut s = Session::new(doc); + let end = Pos::body(0, s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().len()); + let at = s.layout().caret(&end).unwrap(); + let hit = s.layout().hit(at.page, at.x + 20.0, at.top + at.height / 2.0, StoryRef::Body).unwrap(); + s.sel = crate::Selection::caret(hit); + typ(&mut s, ". Dogs bark."); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Cats purr (Smith 2020). Dogs bark."); + let f = s.doc.field_ranges(StoryRef::Body); + assert_eq!(f.len(), 1); + let inside = Pos { off: f[0].start.off + wordcraft_doc::para::OBJ.len_utf8(), ..f[0].start.clone() }; + assert_eq!(s.doc.copy_range(&inside, &f[0].end).plain_text(), "(Smith 2020)"); +} diff --git a/crates/zotero/src/bridge.rs b/crates/zotero/src/bridge.rs index b5d00bba2..c09dd7865 100644 --- a/crates/zotero/src/bridge.rs +++ b/crates/zotero/src/bridge.rs @@ -226,7 +226,7 @@ impl Bridge { Ok(Value::Null) } "Document_cursorInField" => { - let at = s.sel.ordered().0; + let at = if s.sel.is_collapsed() { s.doc.past_field_ends(&s.sel.focus) } else { s.sel.ordered().0 }; let list = self.fields(s); // The innermost field holding the caret: the last one starting before it. let hit = list.iter().enumerate().rfind(|(_, f)| f.contains(&at)); @@ -374,6 +374,9 @@ impl Bridge { if !fields::can_insert_at(&s.doc, &s.sel.ordered().0) { return Err("a citation can't go here (only in the text, a footnote or an endnote)".into()); } + if s.sel.is_collapsed() { + s.sel = Selection::caret(s.doc.past_field_ends(&s.sel.focus)); + } self.fields(s); self.begin_edit(s); if (note == 1 || note == 2) && s.sel.focus.story == StoryRef::Body { diff --git a/crates/zotero/src/tests.rs b/crates/zotero/src/tests.rs index 4a8d5810e..5d72b6605 100644 --- a/crates/zotero/src/tests.rs +++ b/crates/zotero/src/tests.rs @@ -546,3 +546,21 @@ fn a_new_field_is_never_empty_so_zotero_keeps_a_new_bibliography() { z.ok("Document_complete", json!([1])); assert_eq!(z.text(), "Text. Doe, J. (2020)."); } + +/// #898: a click just after a citation puts the caret before its hidden field end. Zotero must +/// see that caret outside the citation (as in Word), and a citation added there goes after the +/// first one, not inside its result. +#[test] +fn a_citation_added_just_after_another_is_not_nested_in_it() { + let mut z = Z::new("Cats purr ", 10); + z.begin(); + z.cite(0, "(Doe 2020)"); + let first = z.s.doc.field_ranges(StoryRef::Body)[0].end.clone(); + z.s.sel = Selection::caret(first); + assert_eq!(z.ok("Document_cursorInField", json!([1, "ReferenceMark"])), Value::Null); + z.cite(0, "(Roe 2019)"); + z.ok("Document_complete", json!([1])); + assert_eq!(z.text(), "Cats purr (Doe 2020)(Roe 2019)"); + let r = z.s.doc.field_ranges(StoryRef::Body); + assert_eq!(r.iter().map(|f| f.depth).collect::>(), [0, 0]); +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index cc8d17572..5c36711a9 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-13 · **Change:** minor (Alt+numpad character codes, #961; 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-13 · **Change:** trivial (Alt+numpad character codes, #961; previously trivial: text typed just after a field goes outside it, #898; 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 @@ -100,6 +100,9 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin isn't linked; typing inside it is. Linking a whole paragraph doesn't link its mark, so the paragraphs Enter makes from it aren't links (#413; `a_link_covers_only_its_own_text`, `typing_at_either_end_of_a_link_is_not_linked`). +- A field covers only its own result: text typed or pasted with the caret just after a field + (a Zotero citation at the end of a paragraph, the caret put there with a click) goes after + the field, not into its result (#898; `typing_after_a_citation_goes_outside_its_field`). - Enter after `---` `===` `***` `___` `~~~` `###` → a bottom border on the paragraph above (single, double, dotted, thick, wave, triple) (`border_line_autoformat`). ⌘Z right after brings the characters back and removes the border; the Enter stays (one undo step) ¹ @@ -187,6 +190,7 @@ Word. |---|---|---| | 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-12 | 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) | | 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) | | 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) | From 156029fe63bf024e3b3f0568ea90d45955827a8a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:51:19 +0200 Subject: [PATCH 043/199] Layout: a list label moves with its text when the paragraph is aligned (#1159) (#1163) * Layout: a list label moves with its text when the paragraph is aligned (#1159) Alignment shifted the text of a line but not the list label of the first line, so a centred or end-aligned bullet or number stayed at the margin while its text moved away. In Word the label stays attached to the start of the text. The first line's label now moves by the same shift (not after a tab stop, where the text before the tab stays too). Left-to-right and right-to-left paragraphs are covered by one test. * Docs: update the progress docs for this change * Docs: put the new sentence in the WordCraft / evidence column Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 ++ crates/layout/src/para.rs | 9 +++++++ crates/layout/src/tests_bidi.rs | 42 +++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 ++-- 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2b9fc164e..70f36e5f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -151,6 +151,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | | 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | | 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks | +| 2026-10-12 | A list label moves with its text when the paragraph is centred or aligned to the end (it stayed at the margin), in left-to-right and right-to-left paragraphs (#1159) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -301,6 +302,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | | 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | | 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) | +| 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 41f9fadf0..5dac2efca 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1943,6 +1943,15 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio for v in xs.iter_mut().skip(align_from - c0) { *v += shift; } + // The list label belongs to the start of the line: it moves with the text (Word keeps + // the bullet attached when a list item is centred or aligned to the end). After a tab + // stop in the line the label stays, as the text before the tab does. + if first + && last_tab.is_none() + && let Some(lab) = pl.label.as_mut() + { + lab.x += shift; + } } let justify = display.is_none() && ((rp.align == Align::Justify && end == LineEnd::Wrap) || rp.align == Align::Distribute); // A line that only fits with shrunk spaces is shrunk, the last line of the paragraph too. diff --git a/crates/layout/src/tests_bidi.rs b/crates/layout/src/tests_bidi.rs index 9fa922974..5d20141f6 100644 --- a/crates/layout/src/tests_bidi.rs +++ b/crates/layout/src/tests_bidi.rs @@ -519,3 +519,45 @@ fn rtl_section_puts_its_first_column_on_the_right() { page.items.iter().filter_map(|i| if let Placed::Rule { x0, x1, .. } = i { ((x0 - x1).abs() < 0.01).then_some(*x0) } else { None }).collect(); assert_eq!(sep, vec![72.0 + 234.0], "line between the columns"); } + +#[test] +fn list_label_moves_with_its_text_when_the_paragraph_is_aligned() { + // Issue #1159: centring or end-aligning a list item moved the text and left the bullet at the + // margin. In Word the label stays attached to the start of the text, in both directions. + for (rtl, text) in [(false, "alpha beta"), (true, "مورد اول")] { + let mut d = doc_of(&[(text, rtl), (text, rtl), (text, rtl), (text, rtl)]); + let id = d.numbering.add_list(wordcraft_doc::ListKind::Bullet); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(3, 1), &|p: &mut ParaProps| { + p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 }) + }) + .unwrap(); + // Start edge (the default), centre, end edge, and a justified one. + for (i, a) in [(1, Align::Center), (2, Align::Right), (3, Align::Justify)] { + let at = Pos::body(i, 0); + d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.align = Some(a)).unwrap(); + } + let l = lay(&d); + // How far the label's near edge is from where the text starts. + let gap = |i: u32| { + let (pl, x) = first_line(&l, i); + let lab = pl.label.as_ref().unwrap_or_else(|| panic!("no label: rtl={rtl} i={i} lines={}", pl.lines.len())); + let text_start = caret_x(&l, i as usize, 0); + if rtl { x + lab.x - text_start } else { text_start - (x + lab.x + lab.width) } + }; + let g0 = gap(0); + assert!(g0 > 0.0 && g0 < 40.0, "rtl={rtl}: label then a tab, then text: {g0}"); + for i in 1..4 { + assert!((gap(i) - g0).abs() < 0.5, "rtl={rtl}: paragraph {i}: label stays {g0} from the text, not {}", gap(i)); + } + // The text really moved: further from the start edge when centred, furthest at the end. + let from_start = |i: u32| if rtl { 540.0 - caret_x(&l, i as usize, 0) } else { caret_x(&l, i as usize, 0) - 72.0 }; + assert!(from_start(1) > from_start(0) + 5.0 && from_start(2) > from_start(1) + 5.0, "rtl={rtl}"); + assert!((from_start(3) - from_start(0)).abs() < 0.5, "rtl={rtl}: justified text starts where the left-aligned one does"); + // The label is inside the text area. + for i in 0..4 { + let (pl, x) = first_line(&l, i); + let lab = pl.label.as_ref().unwrap(); + assert!(x + lab.x >= 71.5 && x + lab.x + lab.width <= 540.5, "rtl={rtl}: paragraph {i}"); + } + } +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2a8bc2bf2..116a56d86 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-13 · **Change:** 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-13 · **Change:** trivial (a list label moves with its first line when the paragraph is centred or end-aligned (#1159); 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, @@ -35,7 +35,7 @@ font can't match Word's metrics. | Drop caps | ✅ | 2026-10-06 | — | | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852) | 1–2 | -| List labels: number formats (decimal, letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `layout::tests::chinese_counting_list_labels_and_their_font_runs`; a label mixing faces (digit + CJK comma) is drawn run by run (#534). Hebrew, Arabic, Hindi, Thai, Vietnamese and Russian formats show decimal | — | +| List labels: number formats (decimal, letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `layout::tests::chinese_counting_list_labels_and_their_font_runs`; a label mixing faces (digit + CJK comma) is drawn run by run (#534). Hebrew, Arabic, Hindi, Thai, Vietnamese and Russian formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | | Body top below a tall header, footer pushes body up | ✅ | #138 | — | @@ -81,6 +81,7 @@ font can't match Word's metrics. |---|---|---| | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | | 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | +| 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | From abe572e7fbaa288674383a2141479db1db89c992 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:01:49 -0400 Subject: [PATCH 044/199] CI: translate Croatian in every interface catalog (#1719) #1114 added Croatian to the proofing-language menu, and #1143 put the interface-translation audit back in the test suite; together they left "Croatian" untranslated, failing untranslated_interface_text and the catalog-coverage tests. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr-latn.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 18 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 7e006c20f..5d3345c3f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1718,6 +1718,7 @@ Hyphenation Options… Опции за сричкопренасяне… Hyphenation zone: Зона на сричкопренасяне: Italian Италиански + Croatian Хърватски Japanese Японски Korean Корейски Limit consecutive hyphens to: Ограничаване на последователните пренасяния до: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 56e6bbe1c..21e051844 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1649,6 +1649,7 @@ Hyphenation Options… Možnosti dělení slov… Hyphenation zone: Pásmo dělení slov: Italian Italština + Croatian Chorvatština Japanese Japonština Korean Korejština Limit consecutive hyphens to: Omezit počet po sobě jdoucích rozdělení: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f2eeb8424..920c52e9e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2011,6 +2011,7 @@ Hyphenation Options… Silbentrennungsoptionen… Hyphenation zone: Silbentrennzone: Italian Italienisch + Croatian Kroatisch Japanese Japanisch Korean Koreanisch Limit consecutive hyphens to: Aufeinanderfolgende Trennstriche begrenzen auf: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7b01800ee..c5163ba13 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2070,6 +2070,7 @@ Hyphenation Options… Opciones de separación de palabras… Hyphenation zone: Zona de separación: Italian Italiano + Croatian Croata Japanese Japonés Korean Coreano Limit consecutive hyphens to: Limitar guiones consecutivos a: diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index f6d794c19..8034975a2 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2064,6 +2064,7 @@ Hyphenation Options… Poolitussätted… Hyphenation zone: Poolitusala: Italian Itaalia + Croatian Horvaadi Japanese Jaapani Korean Korea Limit consecutive hyphens to: Järjestikuseid poolitusi kuni: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 145e960ad..44a3b2160 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1829,6 +1829,7 @@ Hyphenation Options… Options de césure… Hyphenation zone: Zone de césure : Italian Italien + Croatian Croate Japanese Japonais Korean Coréen Limit consecutive hyphens to: Limiter les césures consécutives à : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 742b97865..4df766753 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2187,6 +2187,7 @@ Hyphenation Options… Elválasztási beállítások… Hyphenation zone: Elválasztási zóna: Italian Olasz + Croatian Horvát Japanese Japán Korean Koreai Limit consecutive hyphens to: Egymást követő elválasztások legfeljebb: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6c007b9a7..3c7633bf8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2046,6 +2046,7 @@ Hyphenation Options… Opzioni sillabazione… Hyphenation zone: Area di sillabazione: Italian Italiano + Croatian Croato Japanese Giapponese Korean Coreano Limit consecutive hyphens to: Limita trattini consecutivi a: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1e0c92c82..8cb4684f2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2030,6 +2030,7 @@ Hyphenation Options… ハイフネーションのオプション… Hyphenation zone: ハイフネーション領域: Italian イタリア語 + Croatian クロアチア語 Japanese 日本語 Korean 韓国語 Limit consecutive hyphens to: 連続するハイフンの上限: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b38dc1041..80454c3bd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1570,6 +1570,7 @@ Ionosphere Jonosfera Issue Numer Italian Włoski + Croatian Chorwacki Japanese Japoński Journal Czasopismo naukowe Korean Koreański diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 856f29477..2948dd7e7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2042,6 +2042,7 @@ Hyphenation Options… Opções de hifenização… Hyphenation zone: Zona de hifenização: Italian Italiano + Croatian Croata Japanese Japonês Korean Coreano Limit consecutive hyphens to: Limitar hífens consecutivos a: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c8a6ab830..4c9155b83 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2367,6 +2367,7 @@ preview Title Пример Hyphenation Options… Параметры расстановки переносов… Hyphenation zone: Зона переноса слов: Italian Итальянский + Croatian Хорватский Japanese Японский Korean Корейский Limit consecutive hyphens to: Максимум последовательных переносов: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index c5133e012..7b99e2eda 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2040,6 +2040,7 @@ Hyphenation Options… Opcije deljenja reči… Hyphenation zone: Zona deljenja reči: Italian Italijanski + Croatian Hrvatski Japanese Japanski Korean Korejski Limit consecutive hyphens to: Najviše uzastopnih crtica: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f87bae9ac..f36e13149 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2044,6 +2044,7 @@ Hyphenation Options… Опције дељења речи… Hyphenation zone: Зона дељења речи: Italian Италијански + Croatian Хрватски Japanese Јапански Korean Корејски Limit consecutive hyphens to: Највише узастопних цртица: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8567dd0d4..6be48b985 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2366,6 +2366,7 @@ preview Title Örnek Hyphenation Options… Heceleme Seçenekleri… Hyphenation zone: Heceleme bölgesi: Italian İtalyanca + Croatian Hırvatça Japanese Japonca Korean Korece Limit consecutive hyphens to: Ardışık tireleri şununla sınırla: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4907f8fc7..7724b6102 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2042,6 +2042,7 @@ Hyphenation Options… Параметри перенесення слів… Hyphenation zone: Зона перенесення: Italian Італійська + Croatian Хорватська Japanese Японська Korean Корейська Limit consecutive hyphens to: Обмежити кількість переносів поспіль: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 75408eb47..570b6f178 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2042,6 +2042,7 @@ Hyphenation Options… 断字选项… Hyphenation zone: 断字区: Italian 意大利语 + Croatian 克罗地亚语 Japanese 日语 Korean 韩语 Limit consecutive hyphens to: 连续断字数限制: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ba636a44c..384ad08ed 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2031,6 +2031,7 @@ Hyphenation Options… 斷字選項… Hyphenation zone: 斷字區: Italian 義大利文 + Croatian 克羅埃西亞文 Japanese 日文 Korean 韓文 Limit consecutive hyphens to: 連續斷字數上限: From 25ca4b07528819433546c1d993eaf9f00e1ad2b0 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:05:18 +0200 Subject: [PATCH 045/199] =?UTF-8?q?Full=20screen=20on=20F11=20and=20View?= =?UTF-8?q?=20=E2=80=BA=20Window=20=E2=80=BA=20Full=20Screen=20(#980)=20(#?= =?UTF-8?q?1153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #980: full screen on F11 and View › Window › Full Screen view.fullScreen toggles OS-level full screen (ViewportCommand::Fullscreen), on F11 and ⌃⌘F (macOS); Escape leaves it when nothing else takes Escape. The window's own full screen changes (WM, macOS button) keep the flag in step. Disabled on the web (the browser's own F11). Next Field moves from F11 to Word's other keys, Alt+F1 / Alt+Shift+F1 (Shift+F11 kept). Ribbon button with a drawn icon, keytip, translations in all catalogs. Co-Authored-By: Claude Opus 5.5 * #980: Escape collapses a selection before leaving full screen; F11 only off macOS In full screen, Escape left it even when it had a selection to collapse or a drag to cancel; it now leaves only when nothing else takes Escape. The shortcut read Ctrl+Ctrl+F on Windows and Linux: macOS gets ⌃⌘F and F11, others F11. Docs: Alt+F1 may be taken by GNOME/KDE; keyboard counts re-measured from the registry (152 commands, 172 combinations). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/fields.rs | 34 ++++++- crates/engine/src/cmd/view.rs | 13 +++ crates/engine/src/session.rs | 5 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/et.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr-latn.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/icons.rs | 12 +++ crates/ui-egui/src/keys.rs | 32 +++++- crates/ui-egui/src/keytips.rs | 1 + crates/ui-egui/src/lib.rs | 153 ++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 1 + docs/ui-parity.md | 6 +- 28 files changed, 303 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 70f36e5f9..e2863795d 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-13 · **Change:** trivial (progress log: interface audit back in CI, #1128; previously trivial: progress log: Set Proofing Language… in the Language menu, #860; 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: interface audit back in CI, #1128; previously trivial: progress log: Set Proofing Language… in the Language menu, #860; 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. @@ -148,6 +148,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | +| 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 | | 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | | 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | | 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks | @@ -299,6 +300,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) | | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | +| 2026-10-13 | trivial | Progress log: full screen on F11 (#980) | | 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | | 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | | 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) | diff --git a/crates/engine/src/cmd/fields.rs b/crates/engine/src/cmd/fields.rs index a78b50151..825a0cb83 100644 --- a/crates/engine/src/cmd/fields.rs +++ b/crates/engine/src/cmd/fields.rs @@ -1,6 +1,6 @@ //! Working with fields like Word's field keys: show codes instead of results (Alt+F9, Shift+F9), //! edit a field's code, unlink a field to plain text (Ctrl+Shift+F9), lock and unlock it against -//! updates (Ctrl+F11, Ctrl+Shift+F11) and move from field to field (F11, Shift+F11). +//! updates (Ctrl+F11, Ctrl+Shift+F11) and move from field to field (Alt+F1, Shift+F11 / Alt+Shift+F1; F11 is full screen, #980). //! //! Two kinds of field are handled: a simple field ([`InlineObject::Field`], one object whose result //! is cached text) and a range field ([`InlineObject::FieldStart`] … [`InlineObject::FieldEnd`], @@ -32,8 +32,10 @@ pub fn specs() -> Vec { CommandSpec::new("fields.unlink", "Unlink Fields", "Insert › Text › Quick Parts", unlink).key("Mod+Shift+F9"), CommandSpec::new("fields.lock", "Lock Fields", "Insert › Text › Quick Parts", |s, _| set_locked(s, true)).key("Mod+F11"), CommandSpec::new("fields.unlock", "Unlock Fields", "Insert › Text › Quick Parts", |s, _| set_locked(s, false)).key("Mod+Shift+F11"), - CommandSpec::new("fields.next", "Next Field", "Insert › Text › Quick Parts", |s, _| go(s, true)).key("F11").pure(), - CommandSpec::new("fields.previous", "Previous Field", "Insert › Text › Quick Parts", |s, _| go(s, false)).key("Shift+F11").pure(), + CommandSpec::new("fields.next", "Next Field", "Insert › Text › Quick Parts", |s, _| go(s, true)).key("Alt+F1").pure(), + CommandSpec::new("fields.previous", "Previous Field", "Insert › Text › Quick Parts", |s, _| go(s, false)) + .key("Shift+F11 / Alt+Shift+F1") + .pure(), CommandSpec::new("fields.selected", "Selected Fields", "Insert › Text › Quick Parts", |s, _| { Ok(Value::Array(selected(s).iter().map(|f| f.json()).collect())) }) @@ -231,7 +233,7 @@ fn unlink(s: &mut Session, _: &Value) -> CmdResult { Ok(json!({"fields": fields.len()})) } -/// F11 / Shift+F11: select the next or previous field in the story (wrapping around). +/// Alt+F1 / Shift+F11: select the next or previous field in the story (wrapping around). fn go(s: &mut Session, forward: bool) -> CmdResult { let (a, _) = s.sel.ordered(); let all = story_fields(s, a.story); @@ -311,8 +313,30 @@ mod tests { assert!(!page_text(&mut s).contains("{ AUTHOR }")); } + /// #980: F11 is full screen, as people expect of a desktop app; the field walk keeps Word's + /// other keys, Alt+F1 and Alt+Shift+F1 (and Shift+F11). + #[test] + fn f11_is_full_screen_and_alt_f1_walks_fields() { + let s = Session::new(wordcraft_doc::Document::new()); + let key = |k: &str| s.keymap.resolve(&s.registry, k).map(|c| c.id); + assert_eq!(key("F11"), Some("view.fullScreen")); + assert_eq!(key("Alt+F1"), Some("fields.next")); + assert_eq!(key("Alt+Shift+F1"), Some("fields.previous")); + assert_eq!(key("Shift+F11"), Some("fields.previous")); + assert_eq!(key("Mod+F11"), Some("fields.lock")); + // ⌃⌘F is macOS' own full-screen key; elsewhere Mod is Ctrl, so "Mod+Ctrl+F" would read + // Ctrl+Ctrl+F: F11 only there. + let shortcut = s.registry.get("view.fullScreen").map(|c| c.shortcut); + if cfg!(target_os = "macos") { + assert_eq!(shortcut, Some("Mod+Ctrl+F / F11")); + assert_eq!(key("Mod+Ctrl+F"), Some("view.fullScreen")); + } else { + assert_eq!(shortcut, Some("F11")); + } + } + /// Editing a code changes the instruction and the result; lock keeps a result through updates; - /// unlink turns the field into its text in one undo step; F11/Shift+F11 walk the fields. + /// unlink turns the field into its text in one undo step; Alt+F1/Shift+F11 walk the fields. #[test] fn edit_lock_unlink_and_walk_fields() { let mut s = session_with_fields(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index da156c596..be0477a63 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -90,6 +90,14 @@ pub fn specs() -> Vec { }) .key("Mod+Alt+S") .pure(), + // The window fills the screen, OS-level full screen (#980). F11 as in browsers and most + // desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl, + // so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it. + CommandSpec::new("view.fullScreen", "Full Screen", "View › Window", |s, v| toggle(s, v, |x| &mut x.full_screen)) + .params(r#"{"value"?: bool}"#) + .key(super::mac_or("Mod+Ctrl+F / F11", "F11")) + .when(full_screen_available) + .pure(), CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string()))) .pure(), // Word › File › Options › Advanced › Numeral: how digits look on screen (display @@ -111,6 +119,11 @@ pub fn specs() -> Vec { ] } +/// The browser keeps full screen to itself (its own F11); the web app can't ask for it. +fn full_screen_available(_: &Session) -> Option<&'static str> { + cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)") +} + fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { if read && !s.view.read_mode { // Reading controls are session-only, and reset on each entry to the view. diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f3b45a4d5..9b9d21d86 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -185,6 +185,10 @@ pub struct ViewState { /// Developer › Design Mode: every content control shows its frame and title. #[serde(default)] pub design_mode: bool, + /// View › Window › Full Screen (F11): the window fills the screen (#980). The front end + /// carries it to the window. + #[serde(default)] + pub full_screen: bool, } fn on() -> bool { @@ -242,6 +246,7 @@ impl Default for ViewState { hide_white_space: false, outline: Default::default(), design_mode: false, + full_screen: false, } } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 5d3345c3f..faef7ef61 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2467,6 +2467,9 @@ Leave out the date and time of comments and tracked changes when saving Без дата и час на коментарите и проследените промени при запазване The document you are editing keeps the names and dates; only the saved file leaves them out. Документът, който редактирате, запазва имената и датите; само записаният файл ги пропуска. Privacy Settings Текущи настройки за поверителност + Full Screen Цял екран + Full\nScreen Цял\nекран + Use the browser's full screen (F11) Използвайте режима на цял екран на браузъра (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} инча diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 21e051844..4c9f6235b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2092,6 +2092,9 @@ Leave out the date and time of comments and tracked changes when saving Při ukládání vynechat datum a čas komentářů a sledovaných změn The document you are editing keeps the names and dates; only the saved file leaves them out. Upravovaný dokument si jména a data ponechá; vynechá je jen uložený soubor. Privacy Settings Aktuální nastavení soukromí + Full Screen Celá obrazovka + Full\nScreen Celá\nobrazovka + Use the browser's full screen (F11) Použijte celou obrazovku prohlížeče (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} palce diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 920c52e9e..da8dfc74a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2086,6 +2086,9 @@ Leave out the date and time of comments and tracked changes when saving Beim Speichern Datum und Uhrzeit von Kommentaren und nachverfolgten Änderungen weglassen The document you are editing keeps the names and dates; only the saved file leaves them out. Das bearbeitete Dokument behält Namen und Daten; nur die gespeicherte Datei lässt sie weg. Privacy Settings Aktuelle Datenschutzeinstellungen + Full Screen Vollbild + Full\nScreen Voll-\nbild + Use the browser's full screen (F11) Verwenden Sie den Vollbildmodus des Browsers (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} Zoll diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c5163ba13..ebd4b24e5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2453,6 +2453,9 @@ preview Title Título Leave out the date and time of comments and tracked changes when saving Omitir la fecha y la hora de los comentarios y los cambios controlados al guardar The document you are editing keeps the names and dates; only the saved file leaves them out. El documento que editas conserva los nombres y las fechas; solo el archivo guardado los omite. Privacy Settings Configuración de privacidad actual + Full Screen Pantalla completa + Full\nScreen Pantalla\ncompleta + Use the browser's full screen (F11) Use la pantalla completa del navegador (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} pulg. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 8034975a2..8ca557d77 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2121,6 +2121,9 @@ Leave out the date and time of comments and tracked changes when saving Jäta salvestamisel välja kommentaaride ja jälitatud muudatuste kuupäev ja kellaaeg The document you are editing keeps the names and dates; only the saved file leaves them out. Redigeeritav dokument säilitab nimed ja kuupäevad; need jäetakse välja ainult salvestatud failist. Privacy Settings Praegused privaatsussätted + Full Screen Täisekraan + Full\nScreen Täis-\nekraan + Use the browser's full screen (F11) Kasutage brauseri täisekraani (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} tolli diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 44a3b2160..75b1ffed5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2095,6 +2095,9 @@ Leave out the date and time of comments and tracked changes when saving Omettre la date et l’heure des commentaires et des modifications suivies à l’enregistrement The document you are editing keeps the names and dates; only the saved file leaves them out. Le document en cours de modification garde les noms et les dates ; seul le fichier enregistré les omet. Privacy Settings Paramètres de confidentialité actuels + Full Screen Plein écran + Full\nScreen Plein\nécran + Use the browser's full screen (F11) Utilisez le plein écran du navigateur (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} po diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4df766753..2bad2faaf 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2476,6 +2476,9 @@ Leave out the date and time of comments and tracked changes when saving Mentéskor a megjegyzések és a követett módosítások dátumának és idejének elhagyása The document you are editing keeps the names and dates; only the saved file leaves them out. A szerkesztett dokumentum megtartja a neveket és a dátumokat; csak a mentett fájlból maradnak ki. Privacy Settings Jelenlegi adatvédelmi beállítások + Full Screen Teljes képernyő + Full\nScreen Teljes\nképernyő + Use the browser's full screen (F11) Használja a böngésző teljes képernyős módját (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} hüvelyk diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3c7633bf8..46fad2c93 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2462,6 +2462,9 @@ preview Title Esempio Leave out the date and time of comments and tracked changes when saving Ometti data e ora di commenti e revisioni al salvataggio The document you are editing keeps the names and dates; only the saved file leaves them out. Il documento che stai modificando conserva nomi e date; solo il file salvato li omette. Privacy Settings Impostazioni di privacy attuali + Full Screen Schermo intero + Full\nScreen Schermo\nintero + Use the browser's full screen (F11) Usa lo schermo intero del browser (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} pollici diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8cb4684f2..daaddbfcb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2087,6 +2087,9 @@ Leave out the date and time of comments and tracked changes when saving 保存時にコメントと変更履歴の日時を含めない The document you are editing keeps the names and dates; only the saved file leaves them out. 編集中のドキュメントには名前と日時が残ります。保存したファイルにだけ含まれません。 Privacy Settings 現在のプライバシー設定 + Full Screen 全画面表示 + Full\nScreen 全画面\n表示 + Use the browser's full screen (F11) ブラウザーの全画面表示を使用してください (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} インチ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 80454c3bd..4be2c5507 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2471,6 +2471,9 @@ preview Title Tytuł Leave out the date and time of comments and tracked changes when saving Pomiń datę i godzinę komentarzy i śledzonych zmian przy zapisywaniu The document you are editing keeps the names and dates; only the saved file leaves them out. Edytowany dokument zachowuje nazwiska i daty; pomija je tylko zapisany plik. Privacy Settings Bieżące ustawienia prywatności + Full Screen Pełny ekran + Full\nScreen Pełny\nekran + Use the browser's full screen (F11) Użyj trybu pełnoekranowego przeglądarki (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} cala diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2948dd7e7..86ff9aa02 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2099,6 +2099,9 @@ Leave out the date and time of comments and tracked changes when saving Omitir a data e a hora dos comentários e das alterações controladas ao salvar The document you are editing keeps the names and dates; only the saved file leaves them out. O documento que você está editando mantém os nomes e as datas; só o arquivo salvo os omite. Privacy Settings Configurações de privacidade atuais + Full Screen Tela inteira + Full\nScreen Tela\ninteira + Use the browser's full screen (F11) Use a tela inteira do navegador (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} pol. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4c9155b83..050f874cd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2477,6 +2477,9 @@ preview Title Пример Leave out the date and time of comments and tracked changes when saving Не сохранять дату и время примечаний и отслеживаемых изменений The document you are editing keeps the names and dates; only the saved file leaves them out. Редактируемый документ сохраняет имена и даты; их не будет только в сохранённом файле. Privacy Settings Текущие параметры конфиденциальности + Full Screen Во весь экран + Full\nScreen Во весь\nэкран + Use the browser's full screen (F11) Используйте полноэкранный режим браузера (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} дюйма diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 7b99e2eda..d9aca608c 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2097,6 +2097,9 @@ Leave out the date and time of comments and tracked changes when saving Izostavi datum i vreme komentara i praćenih izmena pri čuvanju The document you are editing keeps the names and dates; only the saved file leaves them out. Dokument koji uređujete zadržava imena i datume; izostavlja ih samo sačuvana datoteka. Privacy Settings Trenutna podešavanja privatnosti + Full Screen Ceo ekran + Full\nScreen Ceo\nekran + Use the browser's full screen (F11) Koristite prikaz preko celog ekrana u pregledaču (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} inča diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f36e13149..d5ce60a5a 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2101,6 +2101,9 @@ Leave out the date and time of comments and tracked changes when saving Изостави датум и време коментара и праћених измена при чувању The document you are editing keeps the names and dates; only the saved file leaves them out. Документ који уређујете задржава имена и датуме; изоставља их само сачувана датотека. Privacy Settings Тренутна подешавања приватности + Full Screen Цео екран + Full\nScreen Цео\nекран + Use the browser's full screen (F11) Користите приказ преко целог екрана у прегледачу (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} инча diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6be48b985..ab261e1f3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2476,6 +2476,9 @@ preview Title Örnek Leave out the date and time of comments and tracked changes when saving Kaydederken açıklamaların ve izlenen değişikliklerin tarih ve saatini dahil etme The document you are editing keeps the names and dates; only the saved file leaves them out. Düzenlediğiniz belge adları ve tarihleri korur; yalnızca kaydedilen dosya bunları içermez. Privacy Settings Geçerli gizlilik ayarları + Full Screen Tam ekran + Full\nScreen Tam\nekran + Use the browser's full screen (F11) Tarayıcının tam ekran modunu kullanın (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} inç diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7724b6102..b0c38d67c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2099,6 +2099,9 @@ Leave out the date and time of comments and tracked changes when saving Не зберігати дату й час приміток і відстежуваних змін The document you are editing keeps the names and dates; only the saved file leaves them out. Документ, який ви редагуєте, зберігає імена й дати; їх не буде лише у збереженому файлі. Privacy Settings Поточні налаштування конфіденційності + Full Screen На весь екран + Full\nScreen На весь\nекран + Use the browser's full screen (F11) Скористайтеся повноекранним режимом браузера (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} дюйма diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 570b6f178..bc68ed579 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2099,6 +2099,9 @@ Leave out the date and time of comments and tracked changes when saving 保存时不包含批注和修订的日期与时间 The document you are editing keeps the names and dates; only the saved file leaves them out. 正在编辑的文档保留姓名和日期;只有保存的文件中不包含它们。 Privacy Settings 当前隐私设置 + Full Screen 全屏 + Full\nScreen 全屏 + Use the browser's full screen (F11) 请使用浏览器的全屏模式 (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} 英寸 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 384ad08ed..fde965f1b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2088,6 +2088,9 @@ Leave out the date and time of comments and tracked changes when saving 儲存時不包含註解和追蹤修訂的日期與時間 The document you are editing keeps the names and dates; only the saved file leaves them out. 正在編輯的文件會保留名稱和日期;只有儲存的檔案不包含它們。 Privacy Settings 目前的隱私設定 + Full Screen 全螢幕 + Full\nScreen 全螢幕 + Use the browser's full screen (F11) 請使用瀏覽器的全螢幕模式 (F11) # #1128: size labels (Mailings, Layout › Size), status-bar messages, Reading Mode and Go To {name} {height} × {width} in {name} {height} × {width} 英寸 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 87ae0ebd2..98e4fc944 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1050,6 +1050,18 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.rect(3.0, 3.0, 17.0, 17.0, c); pen.line_c(&[(3.0, 10.0), (17.0, 10.0)], a); } + "fullScreen" => { + // A screen with arrows out to its four corners. + pen.rect(2.0, 3.5, 18.0, 16.5, c); + pen.line_c(&[(5.0, 8.5), (5.0, 6.0), (7.5, 6.0)], a); + pen.line_c(&[(12.5, 6.0), (15.0, 6.0), (15.0, 8.5)], a); + pen.line_c(&[(15.0, 11.5), (15.0, 14.0), (12.5, 14.0)], a); + pen.line_c(&[(7.5, 14.0), (5.0, 14.0), (5.0, 11.5)], a); + pen.line_c(&[(5.0, 6.0), (8.0, 8.5)], a); + pen.line_c(&[(15.0, 6.0), (12.0, 8.5)], a); + pen.line_c(&[(15.0, 14.0), (12.0, 11.5)], a); + pen.line_c(&[(5.0, 14.0), (8.0, 11.5)], a); + } "sideBySide" | "syncScroll" | "switchWindows" => { pen.rect(2.0, 4.0, 9.5, 16.0, c); pen.rect(10.5, 4.0, 18.0, 16.0, a); diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index a0caa6a71..b62db309e 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -382,6 +382,9 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { let _ = app.run("insert.closeHeader", json!({})); continue; } + if leave_full_screen(app, key, modifiers, true) { + continue; + } app.canvas.swallow_text = dispatch(app, key, modifiers) && modifiers.alt; } _ => {} @@ -389,6 +392,24 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } } +/// Escape leaves full screen (#980) once nothing nearer took it: a pen, a text box, a header, a +/// dialog, a menu or a popup, a drag on the page, and in the document (`canvas`) a selection, +/// which Escape collapses first (`select.collapse`), as in a normal window. +fn leave_full_screen(app: &mut WordApp, key: Key, m: Modifiers, canvas: bool) -> bool { + if key != Key::Escape || !m.is_none() || !app.session.view.full_screen || !app.escape_free { + return false; + } + let c = &app.canvas; + if c.obj_drag.is_some() || c.table_resize.is_some() || c.table_drag.is_some() || c.link_from.is_some() { + return false; + } + if canvas && !app.session.sel.is_collapsed() { + return false; + } + let _ = app.run("view.fullScreen", json!({"value": false})); + true +} + /// Alt+numpad character codes are a Windows convention; on macOS Option+digit types its own /// characters (¡ ™ £…), so they're off there (also in a browser on a Mac). fn alt_codes_supported(ctx: &egui::Context) -> bool { @@ -535,10 +556,13 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { } let events = ctx.input(|i| i.events.clone()); for e in events { - if let egui::Event::Key { key, pressed: true, modifiers, .. } = e - && is_global_shortcut(key, modifiers) - { - dispatch(app, key, modifiers); + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + if leave_full_screen(app, key, modifiers, false) { + continue; + } + if is_global_shortcut(key, modifiers) { + dispatch(app, key, modifiers); + } } } } diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index c50ebd4e0..235de3483 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -333,6 +333,7 @@ fn controls(tab: &str) -> Vec { c("New Window", "view.newWindow"), c("Arrange All", "view.arrangeAll"), c("Split", "view.split"), + c("Full Screen", "view.fullScreen"), c("Macros", "tools.macros"), ], "Help" => vec![d("Help", "about"), c("Community Discord", "ui.discord"), d("Commands", "commands")], diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b140565be..613eec399 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -421,6 +421,12 @@ pub struct WordApp { pub frame_ms: f64, /// The window title last sent; a viewport command schedules a repaint, so only send changes. sent_title: String, + /// Full screen as last asked of the window, and as the window last reported it (#980). + sent_full_screen: bool, + seen_full_screen: Option, + /// Nothing else (a dialog, menu, keytips, the File tab) is open to take Escape this frame, so + /// Escape may leave full screen. Read before anything handles the frame's keys. + escape_free: bool, pub quit_requested: bool, pub autosave: bool, pub word_count: (u64, usize), @@ -522,6 +528,9 @@ impl WordApp { applied_animations: None, frame_ms: 0.0, sent_title: String::new(), + sent_full_screen: false, + seen_full_screen: None, + escape_free: false, quit_requested: false, autosave: true, word_count: (0, 0), @@ -1577,6 +1586,7 @@ impl WordApp { return; } let t = theme::Tokens::get(&ctx); + self.escape_free = self.dialog.is_none() && !self.ui.backstage && self.ui.keytips == keytips::Phase::Off && !egui::Popup::is_any_open(&ctx); // Keytips are read before layout so the frame paints the new state; badges paint after // the ribbon so they sit on top of it. crate::keytips::logic(self, &ctx); @@ -1619,11 +1629,34 @@ impl WordApp { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.sent_title = title; } + self.sync_full_screen(&ctx); self.frame_ms = now_ms() - t0; // Typing and formatting land here, after `logic` has reported. self.report_dirty(); } + /// Carry View › Full Screen (`view.fullScreen`, F11) to the window, and follow the window + /// when the system takes it in or out of full screen (the macOS title bar button, the window + /// manager). Leaving full screen gives the window back its earlier size and place: the system + /// keeps them, and [`window_geometry`] never saves a full-screen size (#980). + fn sync_full_screen(&mut self, ctx: &egui::Context) { + let seen = ctx.input(|i| i.viewport().fullscreen); + if seen != self.seen_full_screen { + self.seen_full_screen = seen; + if let Some(on) = seen + && on != self.session.view.full_screen + { + self.session.view.full_screen = on; + self.sent_full_screen = on; + } + } + let want = self.session.view.full_screen; + if want != self.sent_full_screen { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(want)); + self.sent_full_screen = want; + } + } + /// A click on the window's chrome (ribbon tabs and empty ribbon space, galleries, check /// boxes, the status bar, panes) hands keyboard focus back to the document, as in Word, so the /// caret keeps blinking and typing continues where it was (#1093). egui drops the page's focus @@ -4343,6 +4376,126 @@ mod tests { commands } + /// #980: F11 and View › Full Screen (`view.fullScreen`) take the window to full screen; a + /// second F11 or Escape brings it back. + #[test] + fn f11_and_escape_toggle_full_screen() { + use egui::ViewportCommand::Fullscreen; + 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); + }, + app(), + ); + for _ in 0..6 { + h.step(); + } + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + // Run frames and return the full-screen requests the window got. + let sent = |h: &mut egui_kittest::Harness| { + let mut out = Vec::new(); + for _ in 0..2 { + h.step(); + out.extend(h.output().viewport_output.values().flat_map(|v| v.commands.iter()).filter(|c| matches!(c, Fullscreen(_))).cloned()); + } + out + }; + assert_eq!(sent(&mut h), vec![], "a normal window to start with"); + h.event(key(egui::Key::F11)); + assert_eq!(sent(&mut h), vec![Fullscreen(true)], "F11"); + assert!(h.state().session.view.full_screen); + h.event(key(egui::Key::F11)); + assert_eq!(sent(&mut h), vec![Fullscreen(false)], "F11 again"); + assert!(!h.state().session.view.full_screen); + // The command (ribbon, command search, agents), then Escape. + assert_eq!(h.state_mut().run("view.fullScreen", json!({})).unwrap(), json!({"value": true})); + assert_eq!(sent(&mut h), vec![Fullscreen(true)], "the command"); + h.event(key(egui::Key::Escape)); + assert_eq!(sent(&mut h), vec![Fullscreen(false)], "Escape"); + // Escape in a normal window doesn't touch the window. + h.event(key(egui::Key::Escape)); + assert_eq!(sent(&mut h), vec![]); + // Escape closing a dialog leaves full screen alone. + h.state_mut().run("view.fullScreen", json!({"value": true})).unwrap(); + h.state_mut().run("ui.dialog", json!({"name": "zoom"})).unwrap(); + assert_eq!(sent(&mut h), vec![Fullscreen(true)]); + h.event(key(egui::Key::Escape)); + assert_eq!(sent(&mut h), vec![]); + assert!(h.state().dialog.is_none(), "Escape closed the dialog"); + assert!(h.state().session.view.full_screen, "and only that"); + } + + /// #980: in full screen, Escape with text selected collapses the selection (as in a normal + /// window) and leaves full screen alone; the next Escape, with nothing selected, leaves it. + #[test] + fn escape_collapses_a_selection_before_leaving_full_screen() { + use egui::ViewportCommand::Fullscreen; + 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); + }, + app(), + ); + for _ in 0..6 { + h.step(); + } + let esc = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let sent = |h: &mut egui_kittest::Harness| { + let mut out = Vec::new(); + for _ in 0..2 { + h.step(); + out.extend(h.output().viewport_output.values().flat_map(|v| v.commands.iter()).filter(|c| matches!(c, Fullscreen(_))).cloned()); + } + out + }; + h.state_mut().canvas.focused = true; + h.state_mut().run("text.insert", json!({"text": "Hello world"})).unwrap(); + h.state_mut().run("select.all", json!({})).unwrap(); + h.state_mut().run("view.fullScreen", json!({"value": true})).unwrap(); + assert_eq!(sent(&mut h), vec![Fullscreen(true)]); + assert!(!h.state().session.sel.is_collapsed()); + h.event(esc.clone()); + assert_eq!(sent(&mut h), vec![], "Escape with a selection stays in full screen"); + assert!(h.state().session.sel.is_collapsed(), "and collapses the selection"); + assert!(h.state().session.view.full_screen); + h.event(esc); + assert_eq!(sent(&mut h), vec![Fullscreen(false)], "Escape with nothing selected leaves"); + assert!(!h.state().session.view.full_screen); + } + + /// #980: the window leaving full screen by other means (the window manager, the macOS title + /// bar button) turns View › Full Screen off, and entering it turns it on. + #[test] + fn full_screen_follows_the_window() { + let ctx = egui::Context::default(); + let mut a = app(); + let frame = |a: &mut WordApp, fullscreen: Option| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().fullscreen = fullscreen; + let mut out = ctx.run_ui(input, |ui| a.sync_full_screen(ui.ctx())); + let sent = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(); + out.drop_without_applying_deltas(); + sent.into_iter().filter(|c| matches!(c, egui::ViewportCommand::Fullscreen(_))).collect::>() + }; + assert!(frame(&mut a, Some(false)).is_empty()); + let sent = frame(&mut a, Some(true)); + assert!(sent.is_empty(), "the window went full screen: nothing to ask: {sent:?}"); + assert!(a.session.view.full_screen); + assert!(frame(&mut a, Some(false)).is_empty()); + assert!(!a.session.view.full_screen, "and came back"); + // Asked for: sent once; the window is slow to report it, which doesn't undo the request. + a.run("view.fullScreen", json!({})).unwrap(); + assert_eq!(frame(&mut a, Some(false)), vec![egui::ViewportCommand::Fullscreen(true)]); + assert!(frame(&mut a, Some(false)).is_empty()); + assert!(a.session.view.full_screen); + assert!(frame(&mut a, Some(true)).is_empty()); + assert!(a.session.view.full_screen); + } + /// #311: with System, egui's theme preference stays System, so the native window is never /// pinned to a concrete appearance (macOS would stop reporting OS changes), and our palette /// follows each OS appearance egui reports. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c9a413189..0bf0cca26 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1825,6 +1825,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false); big(ui, app, "split", "Split", "view.split", json!({}), false); + big(ui, app, "fullScreen", "Full\nScreen", "view.fullScreen", json!({}), v.full_screen); }); group(ui, "Macros", None, app, |ui, app| { big(ui, app, "macros", "Macros", "tools.macros", json!({}), false); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8f3afc676..158743b53 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-13 · **Change:** trivial (double-click the margin to create and edit a header or footer, #960; previously trivial: Review › Language and the status bar's language menu open Set Proofing Language… and Language Preferences…, #860; 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-13 · **Change:** trivial (full screen on F11, #980; previously trivial: double-click the margin to create and edit a header or footer, #960; previously trivial: Review › Language and the status bar's language menu open Set Proofing Language… and Language Preferences…, #860; 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 @@ -17,7 +17,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | -| Keyboard shortcuts | **137** commands bound in `CommandSpec::key`, 156 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | +| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | @@ -39,6 +39,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | | File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — | +| 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% | — | | Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 | | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); not yet tried with real screen readers | full | 50% | 5–8 | | Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization | @@ -51,6 +52,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | +| 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | From 37193a14badc45b11e35eb1483d4f13bbc31c971 Mon Sep 17 00:00:00 2001 From: naxvog Date: Wed, 14 Oct 2026 19:05:38 +0200 Subject: [PATCH 046/199] Layout: a paragraph ending with a page break leaves its space after on the old page (#1168) A paragraph whose last line holds the page break keeps its mark on the old page, but its space after was still added after the break, so the next page started that much too low (and the next paragraph lost its "no space before after a page-break mark" treatment). Word adds none of it there, in compatibility modes 12, 14 and 15 alike. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- crates/layout/src/lib.rs | 7 +++++-- crates/layout/src/tests.rs | 31 +++++++++++++++++++++++++++++-- docs/layout-parity.md | 5 +++-- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e2863795d..c83051bd0 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: interface audit back in CI, #1128; previously trivial: progress log: Set Proofing Language… in the Language menu, #860; 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: space after a page break; previously trivial: progress log: 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. @@ -153,6 +153,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | | 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks | | 2026-10-12 | A list label moves with its text when the paragraph is centred or aligned to the end (it stayed at the margin), in left-to-right and right-to-left paragraphs (#1159) | +| 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -305,6 +306,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | | 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) | | 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) | +| 2026-10-13 | trivial | Progress log: space after a page break | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3c07ec412..b7e06d911 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3029,8 +3029,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: _ => {} } } - pb.y += pl.rp.space_after; - pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after, pl.rp.after_auto, label.is_some())); + // A mark that stayed with its page break keeps its space after on that page too: the next page + // starts with the next paragraph (Word, compatibility modes 12 to 15). + let space_after = if pb.after_break_mark { 0.0 } else { pl.rp.space_after }; + pb.y += space_after; + pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, space_after, pl.rp.after_auto, label.is_some())); } /// The outline of the body for an Outline view pass. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index afdf9d0e3..813f9eca1 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4749,8 +4749,8 @@ fn text_after_a_column_break_takes_the_new_column_s_width() { } /// Space before at the top of a page, measured in Word for Windows (32 cases): a paragraph that -/// ends with a page break keeps its mark with the break (nothing of it goes to the new page) and the -/// next paragraph gets no space before there; a paragraph that starts with a page break starts on +/// ends with a page break keeps its mark with the break (nothing of it, not even its space after, +/// goes to the new page) and the next paragraph gets no space before there; a paragraph that starts with a page break starts on /// the new page with its space before; a page break before keeps the space before in compatibility /// modes up to 14 (less the previous paragraph's space after) and drops it in mode 15. #[test] @@ -4777,6 +4777,33 @@ fn space_before_at_the_top_of_a_page_like_word() { assert_eq!(page, 1, "mode {mode}"); assert!((top - plain_top).abs() < 0.05, "mode {mode}: the mark stays with the break and the space goes: {top} vs {plain_top}"); } + // Its space after stays behind too (modes 12 to 15), also when it holds only the break: the next + // paragraph, one with a page break before (no blank page) or a table starts at the top. + let after = |text: &str| { + let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); + p.props = ParaProps { space_before: Some(0.0), space_after: Some(12.4), ..Default::default() }; + wordcraft_doc::para_block(p) + }; + let table_top = |blocks: Vec>| { + let mut d = Document::new(); + d.body = blocks; + d.body.push(std::sync::Arc::new(wordcraft_doc::Block::Table(Table::new(1, 1, 468.0)))); + let l = lay(&d); + let page = l.pages.len() - 1; + let y = l.pages[page].items.iter().find_map(|i| if let Placed::Cell { rect, .. } = i { Some(rect.y) } else { None }); + (page, y) + }; + let (_, alone) = table_top(vec![]); + for text in [brk.to_string(), format!("first{brk}")] { + for mode in [12, 14, 15] { + for (before, pbb) in [(0.0, false), (24.0, false), (0.0, true)] { + let (page, top) = probe_top(vec![after(&text), para("PROBE", before, pbb)], mode); + assert_eq!(page, 1, "mode {mode} {text:?} page break before {pbb}"); + assert!((top - plain_top).abs() < 0.05, "mode {mode} {text:?} before {before}: {top} vs {plain_top}"); + } + } + assert_eq!(table_top(vec![after(&text)]), (1, alone), "{text:?}: a table after the break"); + } // A paragraph starting with a page break: its space before on the new page. let mut d = Document::new(); d.body = vec![para("first", 0.0, false), para(&format!("{brk}PROBE"), 24.0, false)]; diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 116a56d86..140a767e6 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-13 · **Change:** trivial (a list label moves with its first line when the paragraph is centred or end-aligned (#1159); 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-13 · **Change:** trivial (a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: a paragraph ending with a page break leaves its space after on the old page; 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, @@ -29,7 +29,7 @@ font can't match Word's metrics. | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 | | Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — | | Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — | -| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534) | — | +| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534); a paragraph ending with a page break keeps its space after on the old page, so the next page starts at the top (compatibility modes 12 to 15, `layout::tests::space_before_at_the_top_of_a_page_like_word`) | — | | Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested | 1–2 | | Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization | | Drop caps | ✅ | 2026-10-06 | — | @@ -82,6 +82,7 @@ font can't match Word's metrics. | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | | 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) | +| 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | From 5b9010f6adac0003939145780abf55c505e3676a Mon Sep 17 00:00:00 2001 From: DevCoderMax Date: Wed, 14 Oct 2026 14:05:50 -0300 Subject: [PATCH 047/199] Picture transparency/corrections/color are absolute from the original + transparency slider (#1175) --- crates/engine/src/cmd/objects.rs | 86 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/ribbon.rs | 19 +++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 3b1d452cf..9694fc7c0 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -107,7 +107,12 @@ pub fn specs() -> Vec { let b = p::f32(v, "brightness").unwrap_or(0.0).clamp(-100.0, 100.0); let c = p::f32(v, "contrast").unwrap_or(0.0).clamp(-100.0, 100.0); let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0); - adjust(s, move |img| { + // Absolute from the original (like transparency): re-setting all three to 0 + // restores the original pixels instead of stacking on the last pass. + let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?; + let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) }; + let base = s.originals.get(&media).cloned().unwrap_or(media.clone()); + adjust_media(s, &base, move |img| { let mut img = img; if b != 0.0 { img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32)); @@ -128,7 +133,11 @@ pub fn specs() -> Vec { CommandSpec::new("picture.color", "Color", "Picture Format › Adjust", |s, v| { let mode = p::str(v, "mode").unwrap_or("grayscale").to_string(); let sat = p::f32(v, "saturation").unwrap_or(100.0); - adjust(s, move |img| recolor(img, &mode, sat)) + // Absolute from the original (like transparency): saturation 100 restores it. + let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?; + let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) }; + let base = s.originals.get(&media).cloned().unwrap_or(media.clone()); + adjust_media(s, &base, move |img| recolor(img, &mode, sat)) }) .params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#) .when(has_picture), @@ -176,7 +185,15 @@ pub fn specs() -> Vec { .when(has_picture), CommandSpec::new("picture.transparency", "Transparency", "Picture Format › Adjust", |s, v| { let t = p::f32(v, "percent").unwrap_or(50.0).clamp(0.0, 100.0); - adjust(s, move |img| { + // Absolute, not compounding: 0% is always the original pixels fully opaque. + // The base is the pre-edit original (kept for Reset Picture), so dragging a + // slider back and forth never fades what a previous pass already faded. + // (Combining with other pixel edits then re-setting transparency drops those + // edits; a full live-effect stack is future work.) + let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?; + let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) }; + let base = s.originals.get(&media).cloned().unwrap_or(media.clone()); + adjust_media(s, &base, move |img| { let mut rgba = img.to_rgba8(); for px in rgba.pixels_mut() { px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8; @@ -964,7 +981,14 @@ fn size(s: &mut Session, v: &Value) -> CmdResult { fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult { let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?; let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) }; - let bytes = s.doc.media.get(&media).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?; + adjust_media(s, &media, f) +} + +/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead +/// of the picture's current bitmap. The result still chains onto the originals map, so Reset +/// Picture keeps working. +fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult { + let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?; let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?; if (img.width() as u64) * (img.height() as u64) > 80_000_000 { return Err(CmdError::Failed("picture is too large to edit".into())); @@ -973,7 +997,18 @@ fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImag let mut png = Vec::new(); out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?; let key = s.doc.add_media(png, "png"); - let orig = s.originals.get(&media).cloned().unwrap_or(media.clone()); + // Chain onto the ultimate original (the base's, or the current picture's when the base + // is itself derived), so Reset Picture always finds it. + let cur = selected(s).and_then(|(_, o)| match o { + InlineObject::Image { media, .. } => Some(media), + _ => None, + }); + let orig = s + .originals + .get(base) + .cloned() + .or_else(|| cur.as_ref().and_then(|m| s.originals.get(m).cloned())) + .unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone())); s.originals.entry(key.clone()).or_insert(orig); with_obj(s, |o| { if let InlineObject::Image { media, .. } = o { @@ -1481,6 +1516,47 @@ mod tests { assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err()); } + /// Transparency is absolute from the original pixels: 80% then 0% is opaque again + /// (it used to stay faded), and repeating a value is idempotent. Corrections and color + /// re-set the same way: brightness 0 and saturation 100 restore the original. + #[test] + fn transparency_is_absolute_from_the_original() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data})).unwrap(); + let rgba = |s: &Session| { + let (_, o) = selected(s).unwrap(); + let InlineObject::Image { media, .. } = o else { panic!("a picture") }; + let bytes = s.doc.media.get(&media).cloned().unwrap(); + image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::>() + }; + let alpha = |s: &Session| rgba(s).iter().map(|p| p[3]).collect::>(); + let orig = rgba(&s); + assert!(orig.iter().all(|p| p[3] == 255)); + s.run("picture.transparency", &json!({"percent": 80})).unwrap(); + assert!(alpha(&s).iter().all(|&a| a == 50), "255 * 0.2 truncated"); + s.run("picture.transparency", &json!({"percent": 0})).unwrap(); + assert_eq!(rgba(&s), orig, "back to opaque"); + s.run("picture.transparency", &json!({"percent": 50})).unwrap(); + let once = rgba(&s); + assert!(once.iter().all(|p| p[3] == 127), "255 * 0.5"); + s.run("picture.transparency", &json!({"percent": 50})).unwrap(); + assert_eq!(rgba(&s), once, "repeating is idempotent"); + s.run("picture.corrections", &json!({"brightness": 20})).unwrap(); + assert_ne!(rgba(&s), orig); + s.run("picture.corrections", &json!({"brightness": 0})).unwrap(); + assert_eq!(rgba(&s), orig, "brightness 0 restores the original"); + s.run("picture.color", &json!({"mode": "saturation", "saturation": 0})).unwrap(); + assert!(rgba(&s).iter().all(|p| p[0] == p[1] && p[1] == p[2]), "grayscale"); + s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap(); + assert!( + rgba(&s).iter().zip(orig.iter()).all(|(a, b)| a.iter().zip(b.iter()).all(|(x, y)| (*x as i16 - *y as i16).abs() <= 2)), + "saturation 100 restores within float rounding" + ); + s.run("picture.reset", &json!({})).unwrap(); + assert_eq!(rgba(&s), orig, "reset still finds the original"); + } + /// #513: Wrap Text › Fix Position on Page / Move with Text re-anchor a floating picture /// vertically without moving it; one undo step each; an inline picture can't. #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0bf0cca26..dae35aed8 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2205,6 +2205,25 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] { mi(ui, app, &format!("{v:.0}%"), "picture.transparency", json!({"percent": v})); } + ui.separator(); + // An exact value, dragged with a live preview: absolute from the original + // pixels, one undo step for the whole drag (same pattern as Size › W/H). + // The current value isn't stored anywhere, so the slider reopens where it + // was last left for this picture. + let (media, off) = match wordcraft_engine::cmd::objects::selected(&app.session) { + Some((pos, wordcraft_doc::para::InlineObject::Image { media, .. })) => (media, pos.off), + _ => (String::new(), 0), + }; + let key = egui::Id::new(("picture_transparency", media, off)); + let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or(0.0); + let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(0.0..=100.0).suffix("%")); + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, v)); + if r.dragged() && !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run("picture.transparency", json!({"percent": v})); + } }); stack(ui, |ui| { small(ui, app, "picture", Some("Change"), "Change Picture", "ui.changePicture", json!({}), false); From 6e6cfba76538186d2cdd58be6b8d62105c784172 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:06:05 +0200 Subject: [PATCH 048/199] Fonts embedded in a .docx lay out and export its text (#1176) * Fonts embedded in a .docx lay out and export its text (fixes #1171) * Fonts: forget lookups made while an embedded family was missing (#1171) --- crates/docx/src/fonts.rs | 72 ++++++++++++++++++++++++++++++++ crates/docx/src/lib.rs | 2 + crates/docx/src/package.rs | 2 + crates/docx/src/write/embed.rs | 6 +-- crates/engine/src/io.rs | 16 +++++++ crates/engine/src/tests_fonts.rs | 65 ++++++++++++++++++++++++++++ crates/fonts/src/fontdb/user.rs | 12 ++++++ 7 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 crates/docx/src/fonts.rs diff --git a/crates/docx/src/fonts.rs b/crates/docx/src/fonts.rs new file mode 100644 index 000000000..5591f9000 --- /dev/null +++ b/crates/docx/src/fonts.rs @@ -0,0 +1,72 @@ +//! Fonts a file embeds through its font table (#1171). +//! +//! The reader keeps the font table and its font parts as opaque passthrough parts, for writing +//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the +//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1). + +use wordcraft_doc::Document; + +use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART}; + +/// Most font files taken from one document. +const MAX_FONTS: usize = 64; + +/// The font table's references to embedded font files, one per style. +const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"]; + +/// The font files `doc`'s font table embeds, de-obfuscated, in table order. Not checked to be +/// fonts: that is up to the font database. Empty for a document read from anything but a package +/// with a font table. +pub fn embedded_fonts(doc: &Document) -> Vec> { + let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else { + return Vec::new(); + }; + let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default(); + let Some(part) = manifest.parts.get(FONT_TABLE_PART) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for embed in table.children("w:font").flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str()))) { + if out.len() >= MAX_FONTS { + log::warn!("docx: more than {MAX_FONTS} embedded fonts; the rest are not loaded"); + break; + } + let Some(rel) = embed.attr("r:id").and_then(|id| part.rels.iter().find(|r| r.id == id && !r.external)) else { + continue; + }; + let Some(bytes) = doc.passthrough.get(&rel.target) else { + continue; + }; + // No key: the file is stored as is, as with the all-zero key. + let key = match embed.attr("w:fontKey") { + Some(k) => match font_key(k) { + Some(key) => key, + None => { + log::warn!("docx: embedded font {} has an unreadable key {k:?}", rel.target); + continue; + } + }, + None => [0; 16], + }; + let mut data = bytes.to_vec(); + for (i, b) in data.iter_mut().take(32).enumerate() { + *b ^= key.get(i % 16).copied().unwrap_or(0); + } + out.push(data); + } + out +} + +/// The obfuscation key of a `w:fontKey` GUID (`{XXXXXXXX-XXXX-…}`): its 16 bytes, reversed. +fn font_key(guid: &str) -> Option<[u8; 16]> { + let digits: Vec = guid.bytes().filter(|c| !matches!(c, b'{' | b'}' | b'-')).collect(); + if digits.len() != 32 { + return None; + } + let mut key = [0u8; 16]; + for (i, pair) in digits.chunks(2).enumerate() { + let byte = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?; + *key.get_mut(15 - i)? = byte; + } + Some(key) +} diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 5b6ac4296..ba432169c 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -11,6 +11,7 @@ mod chart_spec; mod crypt; mod custom; mod flat; +mod fonts; mod package; mod read; mod smart_art; @@ -26,6 +27,7 @@ pub use crypt::{ is_encrypted, protection_hash, }; pub use flat::{XmlKind, sniff_xml, write as write_flat}; +pub use fonts::embedded_fonts; pub use read::read; pub use smart_art::smart_art_items; pub use wordml2003_write::write as write_wordml2003; diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index dcb956a87..9899118a9 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -58,6 +58,8 @@ pub const MAX_VBA_RELATED: usize = 64; /// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its /// part name. Not a package part: WordCraft's own bookkeeping. pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts"; +/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds. +pub const FONT_TABLE_PART: &str = "word/fontTable.xml"; /// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`. /// Their opaque bytes and related parts use the same bounded manifest as embedded objects. pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots"; diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 8fd0c7881..af51bc392 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -14,7 +14,8 @@ use wordcraft_doc::para::Float; use super::PartRels; use crate::package::{ - CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt, + CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, + rel_is, relative, rt, }; use crate::units::{MAX_LEN_PT, emu, int}; use crate::xml::{self, El, MAX_DEPTH}; @@ -29,9 +30,6 @@ const PART_COST: u64 = 4096; /// Where the stories' parts live: every story this writer makes is a part in `word/`. const STORY_PART: &str = "word/document.xml"; -/// The font table kept from a file (read by `Reader::keep_part_tree`). -const FONT_TABLE_PART: &str = "word/fontTable.xml"; - /// The embedded-object parts of a package being written. pub(crate) struct EmbedWriter<'d> { manifest: EmbeddedManifest, diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cc35d9734..7cedc2a83 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -107,6 +107,22 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { /// whether the file was encrypted. Without the password needed the error satisfies /// [`needs_password`]; with a wrong one, [`wrong_password`]. pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { + let (doc, encrypted) = read_bytes(name, bytes, password)?; + load_embedded_fonts(&doc); + Ok((doc, encrypted)) +} + +/// Load the fonts `doc` embeds (#1171) for this run, in memory only: text set in a font that isn't +/// installed then lays out and exports with it instead of a fallback. Faces already available +/// stay as they are. +fn load_embedded_fonts(doc: &Document) { + let files: Vec> = wordcraft_docx::embedded_fonts(doc).iter().filter_map(|f| wordcraft_fonts::decode_font_file(f).ok()).collect(); + if !files.is_empty() { + wordcraft_fonts::FontDb::global().add_document_fonts(files); + } +} + +fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { let ext = ext_of(name); if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?; diff --git a/crates/engine/src/tests_fonts.rs b/crates/engine/src/tests_fonts.rs index fdc54c030..53dd9b948 100644 --- a/crates/engine/src/tests_fonts.rs +++ b/crates/engine/src/tests_fonts.rs @@ -105,3 +105,68 @@ fn bad_font_input_is_an_error() { assert_eq!(r["pending"], true); assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont")); } + +/// A .docx whose text is set in `family`, embedding `font` through the font table (#1171): as an +/// obfuscated `.odttf` when `key` is a real GUID, as a plain `.ttf` when it is all zeros. +fn docx_embedding(family: &str, font: Option<(&[u8], &str)>, text: &str) -> Vec { + const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + const PR: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; + let rels = |items: &[(&str, &str)]| { + let list: String = items.iter().map(|(t, g)| format!(r#""#)).collect(); + format!(r#"{list}"#) + }; + let mut files: Vec<(String, Vec)> = vec![ + ( + "[Content_Types].xml".into(), + br#""#.to_vec(), + ), + ("_rels/.rels".into(), rels(&[("officeDocument", "word/document.xml")]).into_bytes()), + ("word/_rels/document.xml.rels".into(), rels(&[("fontTable", "fontTable.xml")]).into_bytes()), + ( + "word/document.xml".into(), + format!(r#"{text}"#).into_bytes(), + ), + ]; + let mut table = format!(r#""#); + if let Some((data, key)) = font { + let digits = key.replace(['{', '}', '-'], ""); + let hex: Vec = (0..16).map(|i| u8::from_str_radix(&digits[i * 2..i * 2 + 2], 16).unwrap()).collect(); + let zero = hex.iter().all(|b| *b == 0); + let mut data = data.to_vec(); + // Obfuscation (ECMA-376 Part 1, 17.8.1): the first 32 bytes XOR the key's bytes, reversed. + for i in 0..32 { + data[i] ^= hex[15 - i % 16]; + } + let ext = if zero { "ttf" } else { "odttf" }; + table.push_str(&format!(r#""#)); + files.push(("word/_rels/fontTable.xml.rels".into(), rels(&[("font", &format!("fonts/font1.{ext}"))]).into_bytes())); + files.push((format!("word/fonts/font1.{ext}"), data)); + } + table.push_str(""); + files.push(("word/fontTable.xml".into(), table.into_bytes())); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, &bytes).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +#[test] +fn fonts_embedded_in_a_docx_lay_out_its_text() { + const TEXT: &str = "illicit little lilies fill tiny jill"; + let end_x = + |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); + // Both forms Word and other producers write: an obfuscated .odttf with its key, a plain .ttf + // with the all-zero key. Each is a family no other test or system has. + for (family, key) in [("JetBrains Odtt", "{9F1C2A4E-7B3D-4C58-A1E6-0D2B8F5C3E77}"), ("JetBrains Zero", "{00000000-0000-0000-0000-000000000000}")] + { + let font = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", family); + let missing = end_x(&docx_embedding(family, None, TEXT)); + assert!((missing - mono).abs() > 1.0, "{family}: not embedded, the text falls back: {missing} vs {mono}"); + let x = end_x(&docx_embedding(family, Some((&font, key)), TEXT)); + assert!((x - mono).abs() < 0.01, "{family}: measured with the embedded font: {x} vs {mono} (missing {missing})"); + } +} diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index ff9cee66e..3eb6d57d7 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -243,6 +243,18 @@ impl FontDb { Ok(Installed { font, warning }) } + /// Add the fonts a document embeds (#1171) for this run: not recorded as user fonts, never + /// stored. Faces already available are kept. Lookups made while the family was missing are + /// forgotten ([`FontDb::generation`]), so text set in it now resolves to it. Returns how many + /// faces were added. + pub fn add_document_fonts(&self, files: Vec>) -> usize { + let added = self.add_fonts(files); + if added > 0 { + self.fonts_changed(); + } + added + } + /// Check the faces in `sfnt` and add the new ones, recording them as a user font. fn add_user_faces(&self, name: &str, sfnt: Vec, path: Option) -> Result { if self.read_user().fonts.len() >= MAX_USER_FONTS { From 0128e117b33920e4a049c978abad934b50a3affa Mon Sep 17 00:00:00 2001 From: naxvog Date: Wed, 14 Oct 2026 19:06:24 +0200 Subject: [PATCH 049/199] Layout: table rows share their cell margins and split across pages like Word (#1177) - Every cell in a row gets the row's largest top and bottom cell margin, so a cell without its own margins lines up with its neighbours. - An exact row height covers the top border, top margin and text; the bottom margin comes on top. Dragging a row border, or drawing a line through an exact row, accounts for it. - A row split across pages must keep its cells' bottom margin, border and last paragraph's space after on the first page, and widow/orphan control applies to the paragraphs in its cells. A row whose text fits but whose margin doesn't no longer moves to the next page whole. - Each cell of a split row goes on at the top of the continuation (one cell's widow line no longer pushes its neighbours down), and the continuation is as tall as the cell needing the most room, and at least the rest of the row (a minimum row height holds over the pages). - A nested table's cell the page bottom cuts through goes on from the cut with what is left of it, and the text after it stays below it. - A row of an exact height never splits: it moves to the next page whole. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/table_draw.rs | 35 ++- crates/engine/src/cmd/table_resize.rs | 33 ++- crates/layout/src/lib.rs | 9 +- crates/layout/src/table.rs | 407 +++++++++++++++++--------- crates/layout/src/tests.rs | 356 +++++++++++++++++++++- docs/layout-parity.md | 5 +- 7 files changed, 699 insertions(+), 150 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c83051bd0..f060ca9cb 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: space after a page break; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: table row margins and splitting; previously trivial: progress log: 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. @@ -154,6 +154,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks | | 2026-10-12 | A list label moves with its text when the paragraph is centred or aligned to the end (it stayed at the margin), in left-to-right and right-to-left paragraphs (#1159) | | 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page | +| 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -307,6 +308,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) | | 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) | | 2026-10-13 | trivial | Progress log: space after a page break | +| 2026-10-13 | trivial | Progress log: table row margins and splitting | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/engine/src/cmd/table_draw.rs b/crates/engine/src/cmd/table_draw.rs index 53b202b04..532436cc7 100644 --- a/crates/engine/src/cmd/table_draw.rs +++ b/crates/engine/src/cmd/table_draw.rs @@ -309,6 +309,10 @@ fn split_rows(s: &mut Session, l: &DocLayout, page: usize, y: f32, (xa, xb): (f3 if t.rows.len() >= MAX_ROWS { return Err(CmdError::Failed("the table has too many rows".into())); } + // An exact height leaves out the row's bottom margin (its cells' largest), which the layout + // adds below it: take it off each half so the two rows draw where the line went. + let row_bottom = + t.rows.get(r).map_or(0.0, |row| (0..row.cells.len()).map(|ci| wordcraft_layout::cell_margins(&s.doc, t, r, ci)[2]).fold(0.0f32, f32::max)); let crossed_set: std::collections::BTreeSet = cells.iter().map(|c| c.1).collect(); let merged = |ci: &usize| t.rows.get(r).and_then(|row| row.cells.get(*ci)).is_some_and(|c| c.props.vmerge != VMerge::None); if crossed_set.iter().any(merged) { @@ -320,9 +324,10 @@ fn split_rows(s: &mut Session, l: &DocLayout, page: usize, y: f32, (xa, xb): (f3 new_row.props.header = false; if top.is_finite() && bottom.is_finite() && bottom > top { let rule = if row.props.height_rule == HeightRule::Exact { HeightRule::Exact } else { HeightRule::AtLeast }; - row.props.height = Some((y - top).clamp(1.0, 1584.0)); + let less = if rule == HeightRule::Exact { row_bottom } else { 0.0 }; + row.props.height = Some((y - top - less).clamp(1.0, 1584.0)); row.props.height_rule = rule; - new_row.props.height = Some((bottom - y).clamp(1.0, 1584.0)); + new_row.props.height = Some((bottom - y - less).clamp(1.0, 1584.0)); new_row.props.height_rule = rule; } for (ci, cell) in row.cells.iter_mut().enumerate() { @@ -527,6 +532,32 @@ mod tests { assert!(s.run("table.draw", &json!({})).is_err()); } + /// A line drawn through an exact-height row splits it into two exact rows that draw where + /// the line went: the bottom margin the layout adds below each isn't counted twice. + #[test] + fn a_line_through_an_exact_row_keeps_its_drawn_height() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap(); + let t = s.doc.body.iter_mut().find_map(|b| match std::sync::Arc::make_mut(b) { + Block::Table(t) => Some(t), + _ => None, + }); + let t = t.unwrap(); + t.props.cell_margins = Some([4.0, 5.4, 4.0, 5.4]); + t.rows[0].props.height = Some(60.0); + t.rows[0].props.height_rule = HeightRule::Exact; + s.touch(); + let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1)); + assert!((a.h - 64.0).abs() < 0.5, "an exact 60pt row with a 4pt bottom margin: {a:?}"); + let y = a.y + 25.0; + s.run("table.draw", &json!({"page": 0, "x0": b.x + 1.0, "y0": y, "x1": b.right() - 1.0, "y1": y})).unwrap(); + let t = first_table(&s); + assert_eq!((t.rows[0].props.height_rule, t.rows[1].props.height_rule), (HeightRule::Exact, HeightRule::Exact)); + let (top, low) = (cell_rect(&mut s, 0, 1), cell_rect(&mut s, 1, 1)); + assert!((top.h - 25.0).abs() < 0.5 && (top.y - a.y).abs() < 0.01, "{top:?}"); + assert!((low.bottom() - a.bottom()).abs() < 0.5, "the pair keeps the row's height: {low:?} vs {a:?}"); + } + /// Erasing a horizontal border merges the cells into one laid-out cell (#640): the lower /// row's box is `covered`, the merged cell's box spans both rows, and erasing the border /// below the merge extends it. diff --git a/crates/engine/src/cmd/table_resize.rs b/crates/engine/src/cmd/table_resize.rs index 81fae1ace..0a39febae 100644 --- a/crates/engine/src/cmd/table_resize.rs +++ b/crates/engine/src/cmd/table_resize.rs @@ -524,15 +524,16 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult { return sel_result(s); } // An "at least" height leaves out the cells' margins and border bands, which the layout adds - // (and which depend on the table style): measure what the rows came to and take the extra - // off once. A row its text holds taller keeps the dragged height. + // (and which depend on the table style), and an exact one the bottom margin: measure what the + // rows came to and take the extra off once. A row its text holds taller keeps the dragged + // height. let drawn = drawn_heights(&s.layout(), &s.doc, page, &target); let mut fixed = Vec::new(); let t = s.doc.table_mut(target.story, &target.table)?; for (r, want) in &plan.heights { let Some(got) = drawn.iter().find(|(i, _)| i == r).map(|(_, h)| *h) else { continue }; if let Some(row) = t.rows.get_mut(*r) - && row.props.height_rule == HeightRule::AtLeast + && matches!(row.props.height_rule, HeightRule::AtLeast | HeightRule::Exact) && got > want + 0.05 { row.props.height = Some((want - (got - want)).max(1.0)); @@ -758,6 +759,32 @@ mod tests { assert_eq!(t.rows[1].props.height, None, "the other row is untouched"); } + /// An exact row stays exact when its border is dragged: the stored height leaves out the + /// 4pt bottom margin the layout adds below it, and the drawn row is what was dragged. + #[test] + fn an_exact_row_border_drag_leaves_out_the_bottom_margin() { + let mut s = table_session(2, 2); + let t = s.doc.body.iter_mut().find_map(|b| match std::sync::Arc::make_mut(b) { + wordcraft_doc::Block::Table(t) => Some(t), + _ => None, + }); + let t = t.unwrap(); + t.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]); + t.rows[0].props.height = Some(30.0); + t.rows[0].props.height_rule = HeightRule::Exact; + s.touch(); + let a = cell_rect(&mut s, 0, 0); + assert!((a.h - 34.0).abs() < 0.1, "{a:?}"); + let x = a.x + a.w / 2.0; + s.run("table.resizeDrag", &json!({"page": 0, "x": x, "y": a.bottom(), "toX": x, "toY": a.bottom() + 20.0})).unwrap(); + let t = first_table(&s); + assert_eq!(t.rows[0].props.height_rule, HeightRule::Exact); + let stored = t.rows[0].props.height.unwrap(); + assert!((stored - 50.0).abs() < 0.1, "stored {stored}"); + let h = cell_rect(&mut s, 0, 0).h; + assert!((h - 54.0).abs() < 0.1, "drawn {h}"); + } + /// The handle past the bottom-right corner scales every column and row. #[test] fn the_corner_handle_scales_the_table() { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index b7e06d911..9ebd0a6d5 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use wordcraft_doc::numbering::{Counters, Level}; use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap, WrapText}; -use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat, TextDirection}; +use wordcraft_doc::props::{Border, CharProps, HeightRule, Rgb, TableFloat, TextDirection}; use wordcraft_doc::section::{HeaderSet, SectionProps, SectionStart}; use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef}; use wordcraft_geom::{Point, Rect, Spin}; @@ -3196,7 +3196,12 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl pb.content_bottom = pb.content_bottom.max(pb.y); }; for (ri, row) in tl.rows.iter().enumerate() { - let splittable = !header_rows.contains(&ri) && !t.rows.get(ri).is_some_and(|r| r.props.cant_split); + // Word never splits a row of an exact height: it moves on whole. + let splittable = !header_rows.contains(&ri) + && !t + .rows + .get(ri) + .is_some_and(|r| r.props.cant_split || (r.props.height_rule == HeightRule::Exact && r.props.height.is_some_and(|h| h > 0.0))); let mut rest: Option = None; // On a new page holding only the repeated header rows. let mut fresh = false; diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 102aa174a..7ab6b3d76 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -15,6 +15,21 @@ pub struct RowLayout { pub height: f32, /// Items relative to the table's top-left (x) and the row's top (y). pub items: Vec, + /// The room the row keeps below its text: the bottom cell margin and border band. Part of a + /// row split across pages keeps it too. + pub bottom: f32, + /// The row's cells: the items each one owns and where its content ends. + pub cells: Vec, +} + +/// A cell of a [`RowLayout`]. +pub struct CellPart { + /// Its items in the row's `items` (its fill, text, borders and cell area). + pub items: std::ops::Range, + /// Where its content ends below the row's top, its last paragraph's space after included + /// (what the part of the row left on a page must hold); `None` for turned text and a cell + /// covered by a vertical merge. + pub end: Option, } pub struct TableLayout { @@ -86,9 +101,10 @@ fn default_margins(t: &Table, style: Option<&TableStyleProps>) -> [f32; 4] { t.props.cell_margins.or(style.and_then(|s| s.parts.cell_margins)).unwrap_or(DEFAULT_MARGINS) } -/// The margins (top, left, bottom, right; points) inside cell `cell` of row `row`: its own, else -/// the table's defaults, as the layout uses them. The app's ruler measures a cell's indents from -/// them. +/// The margins (top, left, bottom, right; points) of cell `cell` of row `row`: its own, else the +/// table's defaults. The layout uses the left and right ones as they are (the app's ruler +/// measures a cell's indents from them) but gives every cell in a row the row's largest top and +/// bottom margins. pub fn cell_margins(doc: &wordcraft_doc::Document, t: &Table, row: usize, cell: usize) -> [f32; 4] { let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); t.rows.get(row).and_then(|r| r.cells.get(cell)).and_then(|c| c.props.margins).unwrap_or_else(|| default_margins(t, style.as_ref())) @@ -410,20 +426,31 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava span: usize, /// Turned text: `items` are still in the turned frame (placed in the second pass). turned: Option, + /// Where the content ends below the cell's top (before vertical alignment). + end: f32, } let mut rows: Vec> = Vec::with_capacity(nrows); let mut heights: Vec = Vec::with_capacity(nrows); + let mut bottoms: Vec = Vec::with_capacity(nrows); for (ri, row) in t.rows.iter().enumerate() { let mut g = 0usize; let mut cells = Vec::with_capacity(row.cells.len()); let mut rh = 0.0f32; // The tallest cell margins plus border bands in the row. let mut row_insets = 0.0f32; + let mut row_below = 0.0f32; // Every cell an empty one with a hidden end-of-cell mark: the row is only its borders. let mut collapsed = true; let is_header = header_rows && ri == 0; let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1; let band = banded(t, style.as_ref(), ri); + // Word gives every cell in a row the row's largest top and bottom margins: a cell without + // its own lines up with a neighbour that has 5.65pt ones. + let (row_top, row_bottom) = row + .cells + .iter() + .map(|c| c.props.margins.unwrap_or(margins_def)) + .fold((0.0f32, 0.0f32), |(top, bottom), m| (top.max(m[0]), bottom.max(m[2]))); for (ci, cell) in row.cells.iter().enumerate() { let span = cell.span(); let x0 = colx.get(g).copied().unwrap_or(acc); @@ -431,8 +458,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // Right to left: grid columns run from the right edge, and the start (left) margin // is on the cell's right. let (x0, x1) = if rtl { ((acc - x1).max(0.0), (acc - x0).max(0.0)) } else { (x0, x1) }; - let margins = cell.props.margins.unwrap_or(margins_def); - let margins = if rtl { [margins[0], margins[3], margins[2], margins[1]] } else { margins }; + let m = cell.props.margins.unwrap_or(margins_def); + let margins = if rtl { [row_top, m[3], row_bottom, m[1]] } else { [row_top, m[1], row_bottom, m[3]] }; let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); let mut fill = cell.props.shading; let region = region_of(t, style.as_ref(), ri, g, span, ncols); @@ -513,7 +540,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // grows to fit (the second pass lays them out again if the row ends up taller). let snap = (ctx.counters.clone(), ctx.eq_count); let len = match (row.props.height, row.props.height_rule) { - (Some(h), HeightRule::Exact) if h > 0.0 => (h - insets).clamp(4.0, TURNED_LIMIT), + (Some(h), HeightRule::Exact) if h > 0.0 => (h + row_bottom - insets).clamp(4.0, TURNED_LIMIT), _ => { let probe = layout_box(ctx, story, &cell.blocks, &cpath, TURNED_MAX, text, depth, None).items; (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); @@ -541,10 +568,12 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava (items, height.max(floats_bottom)) }; let h = h + insets; + let end = h - margins[2] - band_b; if cell.props.vmerge != VMerge::Restart { rh = rh.max(h); } row_insets = row_insets.max(insets); + row_below = row_below.max(margins[2] + band_b); cells.push(CellBox { items, h, @@ -558,16 +587,20 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava g, span, turned, + end, }); g += span; } // Word adds the cells' top and bottom margins and border bands to an at-least row height: // a 30pt row with 5pt margins and 0.5pt borders is 40.5pt tall even when its text needs less. + // An exact height gains only the bottom margin: it runs from the top border to the bottom + // of the text area (a 35pt row with 4pt margins steps 39pt). let rh = match (row.props.height, row.props.height_rule) { - (Some(h), HeightRule::Exact) if h > 0.0 => h, + (Some(h), HeightRule::Exact) if h > 0.0 => h + row_bottom, (Some(h), _) if h > 0.0 => rh.max(h + row_insets), _ => rh, }; + bottoms.push(row_below); heights.push(if collapsed && !cells.is_empty() { rh.max(0.0) } else { rh.max(4.0) }); rows.push(cells); } @@ -609,7 +642,9 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava for (ri, cells) in rows.into_iter().enumerate() { let rh = heights.get(ri).copied().unwrap_or(0.0); let mut items = Vec::new(); + let mut parts = Vec::with_capacity(cells.len()); for (ci, c) in cells.into_iter().enumerate() { + let first = items.len(); // A restart cell spans several rows: its height is their sum. let mut span_h = rh; if c.vmerge == VMerge::Restart { @@ -630,6 +665,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava VAlign::Center => ((span_h - c.h) / 2.0).max(0.0), VAlign::Bottom => (span_h - c.h).max(0.0), }; + let end = (c.vmerge != VMerge::Continue && c.turned.is_none()).then_some(c.end + dy); let mut cell_items = c.items; if let Some(tn) = c.turned { dy = 0.0; @@ -693,8 +729,9 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava cell: ci, story, }); + parts.push(CellPart { items: first..items.len(), end }); } - out.push(RowLayout { height: rh, items }); + out.push(RowLayout { height: rh, items, bottom: bottoms.get(ri).copied().unwrap_or(0.0), cells: parts }); } TableLayout { x, width: total, rows: out } } @@ -702,157 +739,251 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava /// Split a row at `cut` (points below the row's top) so the part above fits on this page. /// Paragraph lines break between lines; fills, vertical rules and cell areas are cut; the /// rest moves up to the top of the continuation. `None` when no line fits above the cut. +/// +/// Like Word, the part left on the page keeps the cells' bottom margin and border below its +/// text and a cell's last paragraph its space after (a row only that much taller than the room +/// left splits rather than moving on whole), and widow control keeps two lines of a paragraph on +/// either side of the break. Each cell's text goes on at the top of the continuation, and the +/// continuation is as tall as the cell needing the most room, and at least the rest of the row +/// (a minimum height holds over the pages). pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { - if cut <= 0.0 || cut >= row.height { + // Also rejects a NaN cut. + if !(cut > 0.0 && cut < row.height) { return None; } - // Where each paragraph splits, and how far up the continuation moves. - let mut first_moved = f32::MAX; - let mut kept_any = false; - for it in &row.items { - // Turned text moves as a whole, like a picture. - if let Some(r) = it.turned_bounds() { - if r.bottom() <= cut + 0.01 { - kept_any = true; - } else { - first_moved = first_moved.min(r.y); - } - continue; - } - match it { - Placed::Lines { para, l0, l1, y, .. } => { - let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0); - for k in *l0..*l1 { - let Some(l) = para.lines.get(k) else { continue }; - let (top, bottom) = (y + l.top - base, y + l.top - base + l.height); - if bottom <= cut + 0.01 { - kept_any = true; - } else { - first_moved = first_moved.min(top); - break; - } - } - } - Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. } => { - if rect.bottom() <= cut + 0.01 { - kept_any = true; - } else { - first_moved = first_moved.min(rect.y); - } - } - _ => {} - } - } - if !kept_any { - return None; - } - // Every line fits and only a nested table's cell runs past the cut (a floating table's - // fixed-height row, say): split there rather than move the whole row on. A row that is just - // taller than its text moves on whole, as in Word. + let limit = cut - row.bottom.clamp(0.0, cut); + let fits = |bottom: f32| bottom <= limit + 0.01; + // Each cell's items: every item of a row is in one of its cells. + let cells: Vec<&[Placed]> = row.cells.iter().map(|c| row.items.get(c.items.clone()).unwrap_or(&[])).collect(); + // A nested table's cell is deeper than the row's own. let own_depth = row.items.iter().filter_map(|it| if let Placed::Cell { table, .. } = it { Some(table.0.len()) } else { None }).min(); - let nested_past_cut = - row.items.iter().any(|it| matches!(it, Placed::Cell { rect, table, .. } if Some(table.0.len()) > own_depth && rect.bottom() > cut + 0.01)); - if first_moved == f32::MAX && nested_past_cut { - first_moved = cut; + // The first line of each paragraph that moves on (`l1` when all of them stay), by cell and + // item. + let splits: Vec> = cells + .iter() + .zip(&row.cells) + .map(|(items, c)| { + // Where the cell's text ends: the paragraph ending lowest is the cell's last. + let lowest = + items.iter().filter_map(|it| text_lines(it).and_then(|(p, l0, l1, y)| crate::item_bottom(y, p, l0, l1))).fold(f32::MIN, f32::max); + items + .iter() + .map(|it| { + let Some((para, l0, l1, y)) = text_lines(it) else { return 0 }; + // The cell's last paragraph ends with the cell's content (its space after). + let last = l1 == para.lines.len() && crate::item_bottom(y, para, l0, l1).is_some_and(|b| b >= lowest - 0.01); + line_split(para, (l0, l1), y, limit, c.end.filter(|_| last)) + }) + .collect() + }) + .collect(); + // The top of the first thing that moves on in each cell. + let mut kept_any = false; + let mut moved = vec![f32::MAX; cells.len()]; + for ((items, splits), m) in cells.iter().zip(&splits).zip(&mut moved) { + for (it, &split) in items.iter().zip(splits) { + let top = if let Some(r) = it.turned_bounds() { + // Turned text moves as a whole, like a picture. + if fits(r.bottom()) { + kept_any = true; + None + } else { + Some(r.y) + } + } else { + match it { + Placed::Lines { para, l0, l1, y, .. } => { + kept_any |= split > *l0; + let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0); + (split < *l1).then(|| para.lines.get(split).map_or(*y, |l| y + l.top - base)) + } + Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. } => { + if fits(rect.bottom()) { + kept_any = true; + None + } else { + Some(rect.y) + } + } + // A nested table's cell the cut runs through goes on from the cut, so what is + // below the cut keeps its size and the text after it stays below it (a + // floating table's fixed-height row in an otherwise fitting cell splits the + // row there). A row that is just taller than its text (a minimum height) + // moves on whole, as in Word. + Placed::Cell { rect, table, .. } if Some(table.0.len()) > own_depth && rect.y < cut && rect.bottom() > cut + 0.01 => Some(cut), + _ => None, + } + }; + if let Some(top) = top { + *m = m.min(top); + } + } } - if first_moved == f32::MAX { + let first_moved = moved.iter().copied().fold(f32::MAX, f32::min); + if !kept_any || first_moved == f32::MAX { return None; } - // Keep a little of the cell's top margin on the continuation. - let shift = (first_moved - 2.0).max(0.0); - let (mut a, mut b) = (Vec::new(), Vec::new()); - for it in &row.items { - if let Some(r) = it.turned_bounds() { - let mut it = it.clone(); - if r.bottom() <= cut + 0.01 { - a.push(it); - } else { - it.translate(0.0, -shift); - b.push(it); - } - continue; + // How far up things move: each cell's first line that moves on goes to the top of the + // continuation, keeping a little of the cell's top margin. + let shift_to = |top: f32| (top - 2.0).max(0.0); + let shifts: Vec = moved.iter().map(|&m| shift_to(if m < f32::MAX { m } else { first_moved })).collect(); + // The continuation is as tall as the cell needing the most room (what it moves on and the + // bottom margin, the rest of the row for turned text), and at least the rest of the row. + let full = (row.height - shift_to(first_moved)).max(4.0); + let mut need = row.height - cut; + for ((c, &m), &s) in row.cells.iter().zip(&moved).zip(&shifts) { + if m < f32::MAX { + need = need.max(c.end.map_or(row.height, |e| e + row.bottom) - s); } - match it { - Placed::Lines { story, path, para, l0, l1, x, y, turn } => { - let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0); - let mut split = *l1; - for k in *l0..*l1 { - let Some(l) = para.lines.get(k) else { continue }; - if y + l.top - base + l.height > cut + 0.01 { - split = k; - break; - } - } - if split > *l0 { - a.push(Placed::Lines { story: *story, path: path.clone(), para: para.clone(), l0: *l0, l1: split, x: *x, y: *y, turn: *turn }); - } - if split < *l1 { - let top = para.lines.get(split).map(|l| y + l.top - base).unwrap_or(*y); - b.push(Placed::Lines { - story: *story, - path: path.clone(), - para: para.clone(), - l0: split, - l1: *l1, - x: *x, - y: top - shift, - turn: *turn, - }); - } - } - Placed::Image { rect, .. } - | Placed::Shape { rect, .. } - | Placed::Graphic { rect, .. } - | Placed::Clip { rect, .. } - | Placed::Object { rect, .. } => { - if rect.bottom() <= cut + 0.01 { - a.push(it.clone()); + } + let height = need.clamp(4.0, full); + // What runs to the row's bottom (cell areas, shading, side borders) ends at the + // continuation's. + let rest = (row.height - height).max(0.0); + let geo_shift = |bottom: f32, shift: f32| if bottom >= row.height - 0.01 { rest } else { shift }; + let (mut a, mut b) = (Vec::new(), Vec::new()); + let mut parts = Vec::with_capacity(cells.len()); + for (((c, items), splits), &shift) in row.cells.iter().zip(&cells).zip(&splits).zip(&shifts) { + let start = b.len(); + for (it, &split) in items.iter().zip(splits) { + if let Some(r) = it.turned_bounds() { + let mut it = it.clone(); + if fits(r.bottom()) { + a.push(it); } else { - let mut it = it.clone(); it.translate(0.0, -shift); b.push(it); } + continue; } - // Shading and cell areas: the part above the cut stays, the rest moves up with the - // continuation (a nested table's cell below the cut moves whole). - Placed::Fill { rect, color } => { - let (above, below) = split_rect(*rect, cut, shift); - a.extend(above.map(|rect| Placed::Fill { rect, color: *color })); - b.extend(below.map(|rect| Placed::Fill { rect, color: *color })); - } - Placed::Cell { rect, covered, table, row: r, cell, story } => { - let (above, below) = split_rect(*rect, cut, shift); - let cell_at = |rect| Placed::Cell { rect, covered: *covered, table: table.clone(), row: *r, cell: *cell, story: *story }; - a.extend(above.map(cell_at)); - b.extend(below.map(cell_at)); - } - Placed::Rule { x0, y0, x1, y1, border } => { - if (y0 - y1).abs() < 0.01 { - // Horizontal: the top edge stays, the bottom edge moves; both fragments close. - if *y0 <= 0.01 { - a.push(it.clone()); - b.push(Placed::Rule { x0: *x0, y0: 0.0, x1: *x1, y1: 0.0, border: *border }); - } else if *y0 >= row.height - 0.01 { - a.push(Placed::Rule { x0: *x0, y0: cut, x1: *x1, y1: cut, border: *border }); - b.push(Placed::Rule { x0: *x0, y0: y0 - shift, x1: *x1, y1: y1 - shift, border: *border }); - } else if *y0 <= cut { + match it { + Placed::Lines { story, path, para, l0, l1, x, y, turn } => { + let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0); + if split > *l0 { + a.push(Placed::Lines { + story: *story, + path: path.clone(), + para: para.clone(), + l0: *l0, + l1: split, + x: *x, + y: *y, + turn: *turn, + }); + } + if split < *l1 { + let top = para.lines.get(split).map(|l| y + l.top - base).unwrap_or(*y); + b.push(Placed::Lines { + story: *story, + path: path.clone(), + para: para.clone(), + l0: split, + l1: *l1, + x: *x, + y: top - shift, + turn: *turn, + }); + } + } + Placed::Image { rect, .. } + | Placed::Shape { rect, .. } + | Placed::Graphic { rect, .. } + | Placed::Clip { rect, .. } + | Placed::Object { rect, .. } => { + if fits(rect.bottom()) { a.push(it.clone()); } else { - b.push(Placed::Rule { x0: *x0, y0: y0 - shift, x1: *x1, y1: y1 - shift, border: *border }); + let mut it = it.clone(); + it.translate(0.0, -shift); + b.push(it); } - } else { - let (t, bt) = (y0.min(*y1), y0.max(*y1)); - if t < cut { - a.push(Placed::Rule { x0: *x0, y0: t, x1: *x1, y1: bt.min(cut), border: *border }); - } - if bt > cut { - b.push(Placed::Rule { x0: *x0, y0: (t - shift).max(0.0), x1: *x1, y1: bt - shift, border: *border }); + } + // Shading and cell areas: the part above the cut stays, the rest moves up with the + // continuation (a nested table's cell below the cut moves whole). + Placed::Fill { rect, color } => { + let (above, below) = split_rect(*rect, cut, geo_shift(rect.bottom(), shift)); + a.extend(above.map(|rect| Placed::Fill { rect, color: *color })); + b.extend(below.map(|rect| Placed::Fill { rect, color: *color })); + } + Placed::Cell { rect, covered, table, row: r, cell, story } => { + let (above, below) = split_rect(*rect, cut, geo_shift(rect.bottom(), shift)); + let cell_at = |rect| Placed::Cell { rect, covered: *covered, table: table.clone(), row: *r, cell: *cell, story: *story }; + a.extend(above.map(cell_at)); + b.extend(below.map(cell_at)); + } + Placed::Rule { x0, y0, x1, y1, border } => { + if (y0 - y1).abs() < 0.01 { + // Horizontal: the top edge stays, the bottom edge moves; both fragments close. + if *y0 <= 0.01 { + a.push(it.clone()); + b.push(Placed::Rule { x0: *x0, y0: 0.0, x1: *x1, y1: 0.0, border: *border }); + } else if *y0 >= row.height - 0.01 { + a.push(Placed::Rule { x0: *x0, y0: cut, x1: *x1, y1: cut, border: *border }); + b.push(Placed::Rule { x0: *x0, y0: y0 - rest, x1: *x1, y1: y1 - rest, border: *border }); + } else if *y0 <= cut { + a.push(it.clone()); + } else { + b.push(Placed::Rule { x0: *x0, y0: y0 - shift, x1: *x1, y1: y1 - shift, border: *border }); + } + } else { + let (t, bt) = (y0.min(*y1), y0.max(*y1)); + if t < cut { + a.push(Placed::Rule { x0: *x0, y0: t, x1: *x1, y1: bt.min(cut), border: *border }); + } + if bt > cut { + let shift = geo_shift(bt, shift); + b.push(Placed::Rule { x0: *x0, y0: (t - shift).max(0.0), x1: *x1, y1: bt - shift, border: *border }); + } } } } } + parts.push(CellPart { items: start..b.len(), end: c.end.map(|e| e - shift) }); } - Some((RowLayout { height: cut, items: a }, RowLayout { height: (row.height - shift).max(4.0), items: b })) + // The part left on the page is placed and never split again. + Some(( + RowLayout { height: cut, items: a, bottom: row.bottom, cells: Vec::new() }, + RowLayout { height, items: b, bottom: row.bottom, cells: parts }, + )) +} + +/// A cell paragraph's placed lines (not turned text): the paragraph, its lines `l0..l1` and the +/// top of line `l0`. +fn text_lines(it: &Placed) -> Option<(&ParaLayout, usize, usize, f32)> { + match it { + Placed::Lines { para, l0, l1, y, .. } if it.turned_bounds().is_none() => Some((para, *l0, *l1, *y)), + _ => None, + } +} + +/// The first of lines `l0..l1` of `para` (placed with line `l0`'s top at `y`) that does not fit +/// above `limit`: the last line of a cell's last paragraph needs room down to the cell's content +/// `end`, its space after. Widow control keeps two lines on either side of the break, or moves +/// the paragraph on whole. +fn line_split(para: &ParaLayout, (l0, l1): (usize, usize), y: f32, limit: f32, end: Option) -> usize { + let n = para.lines.len(); + let mut k = l0; + while k < l1 { + let Some(bottom) = crate::item_bottom(y, para, l0, k + 1) else { break }; + let bottom = if k + 1 == n { end.map_or(bottom, |e| e.max(bottom)) } else { bottom }; + if bottom > limit + 0.01 { + break; + } + k += 1; + } + // The body's rule differs: there a widow takes a line along only when three or more lines are + // on the page (`place_para_in_flow`). + if para.rp.widow_control && k > l0 && k < l1 { + // A widow: take one more line along. + if n - k == 1 { + k -= 1; + } + // An orphan (the first line alone): the paragraph moves on whole. + if l0 == 0 && k == 1 { + k = 0; + } + } + k } /// The parts of `r` above `cut` and below it, the latter moved up by `shift` (onto the diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 813f9eca1..95ba1550b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1582,6 +1582,309 @@ fn tall_rows_split_across_pages() { assert!(l2.pages.len() >= 2); } +/// A table row that starts `push` points lower on a page: one cell with a 4pt bottom margin +/// holding `paras` (text, space after) with no space before. Returns the layout and each cell +/// paragraph's lines per page. +fn row_at_page_bottom(push: f32, paras: &[(&str, f32)], widow: bool) -> (DocLayout, Vec>) { + let mut d = Document::from_text("Filler"); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) { + p.props.space_before = Some(push); + p.props.space_after = Some(0.0); + } + let mut t = Table::new(1, 1, 200.0); + t.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]); + t.rows[0].cells[0].blocks = paras + .iter() + .map(|(text, after)| { + let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); + p.props.space_before = Some(0.0); + p.props.space_after = Some(*after); + p.props.widow_control = Some(widow); + wordcraft_doc::para_block(p) + }) + .collect(); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let per_page = (0..paras.len()) + .map(|i| { + l.pages + .iter() + .map(|pg| { + pg.items + .iter() + .filter_map(|it| match it { + Placed::Lines { path, l0, l1, .. } if path.0 == vec![1, 0, 0, i as u32] => Some(l1 - l0), + _ => None, + }) + .sum() + }) + .collect() + }) + .collect(); + (l, per_page) +} + +/// The bottom of the last line of cell paragraph `i` when the row starts at the top of the +/// page's body (no push). +fn cell_line_bottoms(paras: &[(&str, f32)], i: usize) -> Vec { + let (l, _) = row_at_page_bottom(0.0, paras, true); + let mut out = Vec::new(); + for it in &l.pages[0].items { + if let Placed::Lines { path, para, l0, l1, y, .. } = it + && path.0 == vec![1, 0, 0, i as u32] + { + for k in *l0..*l1 { + out.push(item_bottom(*y, para, *l0, k + 1).unwrap_or(0.0)); + } + } + } + out +} + +/// Word splits a row whose text fits above the page bottom but whose bottom cell margin does +/// not: the last line moves on, and widow control takes the line before it along (an eight-line +/// cell paragraph breaks 6 + 2). It used to move the whole row to the next page. +#[test] +fn a_split_row_keeps_its_bottom_margin_and_widow_control() { + let text = "Dotted leader lines to fill in by hand later on. ".repeat(14); + let paras = [(text.as_str(), 0.0)]; + let bottoms = cell_line_bottoms(¶s, 0); + let n = bottoms.len(); + assert!(n >= 6, "{n} lines"); + // Put the last line 2pt above the page bottom (720): the 4pt margin under it doesn't fit. + let push = 720.0 - 2.0 - bottoms[n - 1]; + let (_, lines) = row_at_page_bottom(push, ¶s, true); + assert_eq!(lines[0], vec![n - 2, 2], "widow control: two lines move on"); + let (_, lines) = row_at_page_bottom(push, ¶s, false); + assert_eq!(lines[0], vec![n - 1, 1], "without widow control only the last line moves on"); + // With the margin room to spare, the whole row stays. + let (_, lines) = row_at_page_bottom(push - 3.0, ¶s, true); + assert_eq!(lines[0], vec![n], "the row fits with its margin"); + // Only the first line fits: an orphan, so the paragraph moves on whole. + let push = 720.0 - 4.0 - 1.0 - bottoms[0]; + let (l, lines) = row_at_page_bottom(push, ¶s, true); + assert_eq!(lines[0], vec![0, n], "orphan control moves the paragraph"); + assert!(l.pages[0].items.iter().all(|it| !matches!(it, Placed::Cell { .. })), "and the row with it"); +} + +/// The cell's last paragraph needs its space after on the page too: a row ending in an empty +/// paragraph with 8pt after, whose text fits but whose space after doesn't, splits before it +/// (Word keeps the "Remarks:" line on the page) instead of moving whole. +#[test] +fn a_split_row_keeps_the_last_paragraphs_space_after() { + let paras = [("Remarks:", 0.0), ("", 8.0)]; + let bottom = cell_line_bottoms(¶s, 1).last().copied().unwrap_or(0.0); + // The empty paragraph's line ends 6pt above the page bottom (720): 8pt after + 4pt margin + // don't fit. + let (_, lines) = row_at_page_bottom(720.0 - 6.0 - bottom, ¶s, true); + assert_eq!(lines, vec![vec![1, 0], vec![0, 1]], "split between the paragraphs"); +} + +/// Each cell of a split row goes on at the top of the continuation: widow control moves two +/// lines of one cell's paragraph on, a neighbour of one-line paragraphs only its last line, and +/// both start level on the next page (the neighbour used to start a line lower). The +/// continuation is as tall as the cell needing the most room. +#[test] +fn split_row_cells_each_continue_at_the_top() { + let text = "Dotted leader lines to fill in by hand later on. ".repeat(14); + let row = |push: f32, singles: usize| { + let mut d = Document::from_text("Filler"); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) { + p.props.space_before = Some(push); + p.props.space_after = Some(0.0); + } + let mut t = Table::new(1, 2, 400.0); + t.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]); + let para = |s: &str| { + let mut p = wordcraft_doc::Paragraph::with_text(s, Default::default()); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + p.props.widow_control = Some(true); + wordcraft_doc::para_block(p) + }; + t.rows[0].cells[0].blocks = vec![para(&text)]; + t.rows[0].cells[1].blocks = (0..singles).map(|i| para(&format!("Item {i}"))).collect(); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + lay(&d) + }; + // The long paragraph's line bottoms with the row at the top of the page; the neighbour gets + // as many one-line paragraphs. + let l = row(0.0, 1); + let Some((y, para, n)) = l.pages[0].items.iter().find_map(|it| match it { + Placed::Lines { path, y, para, l1, .. } if path.0 == vec![1, 0, 0, 0] => Some((*y, para.clone(), *l1)), + _ => None, + }) else { + panic!("no cell text") + }; + assert!(n >= 6, "{n} lines"); + let bottoms: Vec = (1..=n).map(|k| item_bottom(y, ¶, 0, k).unwrap()).collect(); + let line = bottoms[1] - bottoms[0]; + // The last lines end 2pt above the page bottom (720): the 4pt margin under them doesn't fit. + let l = row(720.0 - 2.0 - bottoms[n - 1], n); + assert_eq!(l.pages.len(), 2); + let tops: Vec<(usize, u32, f32)> = l.pages[1] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { path, l1, l0, y, .. } if path.0.len() == 4 => Some((l1 - l0, path.0[2], *y)), + _ => None, + }) + .collect(); + assert_eq!(tops.iter().map(|t| (t.0, t.1)).collect::>(), vec![(2, 0), (1, 1)], "{tops:?}"); + assert!((tops[0].2 - tops[1].2).abs() < 0.01, "both cells go on at the top: {tops:?}"); + let cells: Vec = l.pages[1].items.iter().filter_map(|it| if let Placed::Cell { rect, .. } = it { Some(*rect) } else { None }).collect(); + assert_eq!(cells.len(), 2); + for c in &cells { + assert!((c.y - (tops[0].2 - 2.0)).abs() < 0.01, "{cells:?} {tops:?}"); + // Two lines, the 2pt kept of the top margin and the 4pt bottom margin (and border band). + assert!(c.h >= 2.0 * line + 6.0 - 0.05 && c.h <= 2.0 * line + 7.0, "{} vs {}", c.h, 2.0 * line + 6.0); + } +} + +/// Word never splits a row of an exact height: one the page bottom cuts through, its text +/// included, moves to the next page whole (an "at least" row of that height splits). +#[test] +fn exact_rows_move_on_whole() { + let text = "Dotted leader lines to fill in by hand later on. ".repeat(14); + let lay_row = |rule: wordcraft_doc::props::HeightRule| { + let mut d = Document::from_text("Filler"); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) { + p.props.space_before = Some(560.0); + p.props.space_after = Some(0.0); + } + let mut t = Table::new(1, 1, 200.0); + t.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]); + t.rows[0].props.height = Some(120.0); + t.rows[0].props.height_rule = rule; + let mut p = wordcraft_doc::Paragraph::with_text(&text, Default::default()); + p.props.space_after = Some(0.0); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)]; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + l.pages + .iter() + .map(|pg| { + pg.items + .iter() + .filter_map(|it| if let Placed::Lines { path, l0, l1, .. } = it { (path.0.len() == 4).then_some(l1 - l0) } else { None }) + .sum() + }) + .collect::>() + }; + let exact = lay_row(wordcraft_doc::props::HeightRule::Exact); + assert_eq!(exact.len(), 2); + assert_eq!(exact[0], 0, "the exact row moves on whole: {exact:?}"); + let at_least = lay_row(wordcraft_doc::props::HeightRule::AtLeast); + assert!(at_least[0] > 0 && at_least.get(1).is_some_and(|n| *n > 0), "an at-least row splits: {at_least:?}"); +} + +/// A nested table's cell the page bottom cuts through goes on at the top of the next page with +/// what is left of it, and the paragraph after the nested table below it, while its neighbour's +/// text runs on too. The cell's shift used to come from that paragraph (below the page bottom), +/// which squashed the nested cell to 2pt on the next page. +#[test] +fn a_nested_cell_cut_by_the_page_bottom_keeps_its_rest() { + let mut d = Document::from_text("Filler"); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) { + p.props.space_before = Some(400.0); + p.props.space_after = Some(0.0); + } + let mut inner = Table::new(1, 1, 150.0); + inner.rows[0].props.height = Some(300.0); + inner.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact; + let mut t = Table::new(1, 2, 400.0); + let text = "Dotted leader lines to fill in by hand later on. ".repeat(40); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))]; + t.rows[0].cells[1].blocks = + vec![std::sync::Arc::new(Block::Table(inner)), wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("After", Default::default()))]; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 2); + let nested = |pg: usize| { + l.pages[pg].items.iter().find_map(|it| if let Placed::Cell { rect, table, .. } = it { (table.0.len() > 1).then_some(*rect) } else { None }) + }; + let (Some(a), Some(b)) = (nested(0), nested(1)) else { panic!("the nested cell is on both pages") }; + assert!((a.bottom() - 720.0).abs() < 0.5, "cut at the page bottom: {a:?}"); + assert!(a.h + b.h >= 300.0 - 0.5 && a.h + b.h <= 300.0 + 2.5, "the nested cell keeps its height: {a:?} + {b:?}"); + let after = l.pages[1].items.iter().find_map(|it| match it { + Placed::Lines { path, y, .. } if path.0 == vec![1, 0, 1, 1] => Some(*y), + _ => None, + }); + assert!(after.is_some_and(|y| y >= b.bottom() - 0.01), "the paragraph after it follows it: {after:?} {b:?}"); + // The neighbour's text goes on at the top too, every line once. + let lines: usize = l + .pages + .iter() + .flat_map(|pg| &pg.items) + .map(|it| if let Placed::Lines { path, l0, l1, .. } = it { if path.0 == vec![1, 0, 0, 0] { l1 - l0 } else { 0 } } else { 0 }) + .sum(); + let total = l.pages[0].items.iter().find_map(|it| match it { + Placed::Lines { path, para, .. } if path.0 == vec![1, 0, 0, 0] => Some(para.lines.len()), + _ => None, + }); + assert_eq!(Some(lines), total); +} + +/// A row of a minimum ("at least") height taller than its text that splits across pages keeps +/// its height over them: the continuation holds the rest of the row, not just the rest of the +/// text (no public Word reference crosses a page with such a row; this keeps the earlier +/// behaviour). +#[test] +fn a_split_at_least_row_keeps_its_height() { + let mut d = Document::from_text("Filler"); + let mut t = Table::new(1, 1, 400.0); + t.rows[0].props.height = Some(1200.0); + t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + let text = "Dotted leader lines to fill in by hand later on. ".repeat(80); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))]; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let parts: Vec = l + .pages + .iter() + .flat_map(|pg| &pg.items) + .filter_map(|it| if let Placed::Cell { rect, table, .. } = it { (table.0.len() == 1).then_some(rect.h) } else { None }) + .collect(); + assert_eq!(parts.len(), 2, "{parts:?}"); + // 1200pt and the 0.5pt top and bottom border bands. + let sum: f32 = parts.iter().sum(); + assert!((sum - 1201.0).abs() < 0.05, "{parts:?}"); +} + +/// A cut that is NaN or infinite, or a row of a NaN height, splits nothing (`clamp` used to panic +/// on a NaN cut). +#[test] +fn split_row_rejects_a_hostile_cut() { + use crate::table::{CellPart, RowLayout, split_row}; + let d = Document::from_text("Line"); + let l = lay(&d); + let Some(Placed::Lines { story, path, para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).cloned() else { + panic!("no line") + }; + let line = |y: f32| Placed::Lines { story, path: path.clone(), para: para.clone(), l0: 0, l1: 1, x: 0.0, y, turn: TextDirection::Horizontal }; + let row = |height: f32, bottom: f32| RowLayout { + height, + items: vec![line(0.0), line(150.0)], + bottom, + cells: vec![CellPart { items: 0..2, end: Some(170.0) }], + }; + for cut in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -1.0, 200.0] { + assert!(split_row(&row(200.0, 4.0), cut).is_none(), "{cut}"); + } + assert!(split_row(&row(f32::NAN, 4.0), 100.0).is_none()); + assert!(split_row(&row(f32::INFINITY, 4.0), f32::INFINITY).is_none()); + // A NaN or infinite bottom margin doesn't panic either. + for bottom in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let _ = split_row(&row(200.0, bottom), 100.0); + let _ = split_row(&row(f32::INFINITY, bottom), 100.0); + } + // Cell ranges past the items are ignored. + let mut r = row(200.0, 4.0); + r.cells.push(CellPart { items: 5..9, end: None }); + r.cells.push(CellPart { items: std::ops::Range { start: 2, end: 1 }, end: None }); + assert!(split_row(&r, 100.0).is_some()); +} + #[test] fn repeated_header_is_followed_by_the_next_row_even_when_it_overflows() { // A one-column table at the top of a letter page (body 648 pt), then an empty paragraph. Returns @@ -2352,6 +2655,50 @@ fn at_least_rows_add_cell_margins_and_border_bands() { assert!((tops[0] - (72.0 + 0.5 + 5.0)).abs() < 0.01, "first cell text at {}", tops[0]); } +/// Word gives every cell in a row the row's largest top and bottom margins: a cell without its +/// own lines up with a neighbour that has them, and the row is as tall as if all had them. +#[test] +fn a_row_shares_its_largest_top_and_bottom_margins() { + let mut d = Document::from_text(""); + let mut t = Table::new(3, 2, 300.0); + t.props.cell_margins = Some([0.0, 5.4, 0.0, 5.4]); + t.rows[0].cells[0].props.margins = Some([6.0, 5.4, 3.0, 5.4]); + t.rows[0].cells[1].props.margins = Some([2.0, 5.4, 7.0, 5.4]); + for (ri, row) in t.rows.iter_mut().enumerate() { + for (c, text) in row.cells.iter_mut().zip(["one", "two"]) { + c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("{text}{ri}"), Default::default()))]; + } + } + d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t))]; + let tops = line_tops(&lay(&d)); + assert_eq!(tops.len(), 6, "{tops:?}"); + assert!((tops[0] - tops[1]).abs() < 0.01, "both cells start 6pt down: {tops:?}"); + assert!((tops[0] - (72.0 + 0.5 + 6.0)).abs() < 0.01, "below the 0.5pt top border: {tops:?}"); + // The rows below have no margins: the second is one line tall, and the first is a line plus + // 6pt above and 7pt below it. + let line = tops[4] - tops[2]; + assert!((tops[2] - tops[0] - (line + 7.0)).abs() < 0.01, "{tops:?}"); +} + +/// An exact row height gains only the bottom cell margin (Word: a 35pt exact row with 4pt top +/// and bottom margins steps 39pt; the text starts 4pt below its top). +#[test] +fn exact_rows_add_only_the_bottom_margin() { + use wordcraft_doc::props::HeightRule; + let mut d = Document::from_text(""); + let mut t = Table::new(2, 1, 200.0); + t.props.cell_margins = Some([4.0, 5.4, 4.0, 5.4]); + for (r, text) in t.rows.iter_mut().zip(["one", "two"]) { + r.props.height = Some(35.0); + r.props.height_rule = HeightRule::Exact; + r.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]; + } + d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t))]; + let tops = line_tops(&lay(&d)); + assert!((tops[1] - tops[0] - 39.0).abs() < 0.01, "row pitch {}", tops[1] - tops[0]); + assert!((tops[0] - (72.0 + 0.5 + 4.0)).abs() < 0.01, "first cell text at {}", tops[0]); +} + /// `cell_margins` is what the layout uses: a cell's own margins, else the table's, else Word's; /// each cell's text starts its left margin inside the cell. #[test] @@ -3300,7 +3647,7 @@ fn a_floating_table_in_the_footer_leaves_the_footer_where_it_is() { #[test] fn a_split_row_moves_shading_below_the_cut_with_it() { - use crate::table::{RowLayout, split_row}; + use crate::table::{CellPart, RowLayout, split_row}; let line_row = |y: f32| { let d = Document::from_text("Line"); let l = lay(&d); @@ -3311,7 +3658,12 @@ fn a_split_row_moves_shading_below_the_cut_with_it() { }; let fill = |y: f32, h: f32| Placed::Fill { rect: wordcraft_geom::Rect::new(0.0, y, 100.0, h), color: wordcraft_doc::props::Rgb(200, 0, 0) }; // The row's own shading (from its top) and a nested cell's shading starting below the cut. - let row = RowLayout { height: 200.0, items: vec![line_row(0.0), line_row(150.0), fill(0.0, 200.0), fill(150.0, 50.0)] }; + let row = RowLayout { + height: 200.0, + items: vec![line_row(0.0), line_row(150.0), fill(0.0, 200.0), fill(150.0, 50.0)], + bottom: 0.0, + cells: vec![CellPart { items: 0..4, end: None }], + }; let (a, b) = split_row(&row, 100.0).expect("splits"); let fills = |r: &RowLayout| { r.items.iter().filter_map(|i| if let Placed::Fill { rect, .. } = i { Some((rect.y, rect.h)) } else { None }).collect::>() diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 140a767e6..556c5bd5a 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-13 · **Change:** trivial (a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: a paragraph ending with a page break leaves its space after on the old page; 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-13 · **Change:** trivial (a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: table rows share their cell margins, exact rows add the bottom margin and never split, rows split across pages like Word; 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, @@ -40,7 +40,7 @@ font can't match Word's metrics. | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | | Body top below a tall header, footer pushes body up | ✅ | #138 | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | -| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138 | — | +| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum (`layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); cell preferred widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | @@ -83,6 +83,7 @@ font can't match Word's metrics. | 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) | | 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | +| 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | From 4cb013faa1e3b2852bd69ddaf30f2fa0f631e2ac Mon Sep 17 00:00:00 2001 From: Raymond Thurman Date: Wed, 14 Oct 2026 12:06:38 -0500 Subject: [PATCH 050/199] Stop Space and Enter from resetting the X11 input context (#1179) Space and Enter count as a click on the focused canvas. request_focus always interrupts IME, and on X11 winit then destroys the XIM context. fcitx swallows every later key. Ignore that keyboard click. Pointer clicks, accessibility clicks, and drags still focus the canvas. --- crates/ui-egui/src/canvas.rs | 61 ++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7c2385615..1047c7ef2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1241,9 +1241,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() && !in_balloon { deselect_balloon(app, ui.ctx()); } - // Focus: the canvas takes keyboard focus on click and keeps Tab/arrows. - if resp.clicked() || resp.drag_started() || app.canvas.want_focus { + // Space and Enter also set `Response::clicked` on a focused click-sensing widget + // (egui's fake primary click). `request_focus` always interrupts IME; on X11 that + // destroys the XIM context and fcitx then drops every later key (#1169). A pointer + // click, an accessibility click, or a drag still takes focus, including a click on + // a canvas that already has it, so a composition commits. `want_focus` focuses only + // while the canvas does not, and does not interrupt an editor that already has it. + let keyboard_click = ui.memory(|m| m.has_focus(resp.id)) && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); + let pointer_click = resp.clicked_by(egui::PointerButton::Primary); + let accesskit_click = resp.clicked() && !pointer_click && !keyboard_click; + let focus_click = pointer_click || accesskit_click || resp.drag_started(); + if focus_click || (app.canvas.want_focus && !resp.has_focus()) { resp.request_focus(); + } + if focus_click || app.canvas.want_focus { app.canvas.want_focus = false; } if resp.has_focus() { @@ -3768,6 +3779,52 @@ mod tests { let _ = collapse_white_space(&[], &[], 1.0, 1.0); } + /// #1169: Space and Enter count as `Response::clicked` on the focused canvas, and + /// `request_focus` always interrupts IME. On X11 that recreates the XIM context and + /// fcitx drops every later key. Those keys still type, and they must not set + /// `should_interrupt_composition`. A real pointer click still does, so a composition + /// commits when the caret moves. + #[test] + fn space_and_enter_on_the_canvas_do_not_interrupt_ime() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("document.setText", json!({"text": "The"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + assert!(h.state().canvas.focused, "the canvas takes focus when the window opens"); + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let ime_interrupted = + |h: &egui_kittest::Harness<'_, WordApp>| h.output().platform_output.ime.as_ref().map(|ime| ime.should_interrupt_composition); + h.event(key(egui::Key::Space)); + h.event(egui::Event::Text(" ".into())); + h.step(); + assert_eq!(ime_interrupted(&h), Some(false), "space must not recreate the XIM context"); + let text = h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); + assert!(text.contains("The") && text.contains(' '), "the space is still inserted: {text:?}"); + assert!(h.state().canvas.focused, "the canvas keeps the keyboard"); + h.event(key(egui::Key::Enter)); + h.step(); + assert_eq!(ime_interrupted(&h), Some(false), "enter must not recreate the XIM context"); + assert!(h.state().canvas.focused, "enter leaves the canvas focused"); + + let at = h.state().canvas.page_rects.first().map(|r| r.center()).expect("a page"); + h.event(egui::Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + assert_eq!(ime_interrupted(&h), Some(true), "a click still interrupts composition"); + } + fn page_at_index(rects: &[Rect], p: Pos2) -> Option { nearest_page(rects, p) } From 533615620ef91ae57fa7aa4cd77fd98b2179e693 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 19:19:28 +0200 Subject: [PATCH 051/199] Equations: bound parser tree depth, escape \text in LaTeX, merge runs everywhere (#1456) (#1457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Math: bound the depth the equation parsers build (#1456) x^1^1^1…, a/a/a… and \sum\sum… built left-nested trees thousands of levels deep within the input cap; clean, Clone, PartialEq, Drop and serde then overflowed small stacks. Wrapping an existing node (script base, numerator, accent, limit base, n-ary body) is skipped once it is already 32 levels deep (iterative check); the operator is text (linear) or dropped (LaTeX). Co-Authored-By: Claude Sonnet 5.5 * Math: escape \text{…} in LaTeX output so it reads back unchanged (#1456) Normal-text runs were written verbatim, so a}b\c became \text{a}b\c} and read back as ab\c. Escape \ { } % $ # & _ ~ (backslash as \backslash{}); \backslash is now a named symbol and raw_arg ignores the empty braces after a control word. A # inside normal text no longer numbers the equation. Co-Authored-By: Claude Sonnet 5.5 * Math: merge runs inside every structure, not just some (#1456) merge_runs only recursed into fractions, scripts, radicals, n-ary operators, delimiters and functions, so the LaTeX parser left \overline{ab} as two runs where the linear parser gives one. It now recurses through map_args for every node kind. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/doc/src/math.rs | 164 ++++++++++++++++++++++++++++++--- crates/doc/src/math_latex.rs | 58 ++++++++++-- crates/doc/src/math_linear.rs | 53 ++++++++--- crates/doc/src/math_symbols.rs | 1 + 5 files changed, 241 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f060ca9cb..3d14d167b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -155,6 +155,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | A list label moves with its text when the paragraph is centred or aligned to the end (it stayed at the margin), in left-to-right and right-to-left paragraphs (#1159) | | 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page | | 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole | +| 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs index 5befd774c..470ef46fb 100644 --- a/crates/doc/src/math.rs +++ b/crates/doc/src/math.rs @@ -340,7 +340,7 @@ fn yes() -> bool { /// A row holds an equation-number mark (`#`) in a top-level run. pub fn has_number_mark(row: &[MNode]) -> bool { - row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && r.text.contains('#'))) + row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#'))) } /// Integral signs (their limits default to sub/sup positions). @@ -701,7 +701,7 @@ pub fn math_class(c: char) -> MClass { } } -/// Merge adjacent runs with the same formatting; recurse into structures. +/// Merge adjacent runs with the same formatting; recurse into every structure. pub fn merge_runs(a: Arg) -> Arg { let mut out: Arg = Vec::with_capacity(a.len()); for n in a { @@ -722,23 +722,79 @@ pub fn merge_runs(a: Arg) -> Arg { } MNode::Run(r) } - MNode::Frac { kind, num, den } => MNode::Frac { kind, num: merge_runs(num), den: merge_runs(den) }, - MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: merge_runs(base), sub: merge_runs(sub), sup: merge_runs(sup) }, - MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: merge_runs(deg), deg_hide, e: merge_runs(e) }, - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => { - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: merge_runs(sub), sup: merge_runs(sup), e: merge_runs(e) } - } - MNode::Delim { beg, end, sep, grow, shp_match, elems } => { - MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(merge_runs).collect() } - } - MNode::Func { name, e } => MNode::Func { name: merge_runs(name), e: merge_runs(e) }, - other => other, + other => crate::math_linear::map_args(other, &merge_runs), }; out.push(n); } out } +/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json +/// stops reading at 128 nested containers and a node costs two (object and array). +pub(crate) const WRAP_DEPTH: usize = MAX_DEPTH / 2; + +/// Calls `f` with every argument of `n`. +fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) { + match n { + MNode::Run(_) => {} + MNode::Frac { num, den, .. } => { + f(num); + f(den); + } + MNode::Script { base, sub, sup, .. } => { + f(base); + f(sub); + f(sup); + } + MNode::Rad { deg, e, .. } => { + f(deg); + f(e); + } + MNode::Nary { sub, sup, e, .. } => { + f(sub); + f(sup); + f(e); + } + MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)), + MNode::Func { name, e } => { + f(name); + f(e); + } + MNode::Lim { e, lim, .. } => { + f(e); + f(lim); + } + MNode::Acc { e, .. } + | MNode::Bar { e, .. } + | MNode::BorderBox { e, .. } + | MNode::Boxed { e } + | MNode::GroupChr { e, .. } + | MNode::Phant { e, .. } => f(e), + MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)), + MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)), + } +} + +/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so +/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`, +/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the +/// size of `n`, which the parsers' input caps bound. +pub(crate) fn too_deep(n: &MNode) -> bool { + let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)]; + while let Some((n, d)) = stack.pop() { + if d >= WRAP_DEPTH { + return true; + } + each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1)))); + } + false +} + +/// Any of `a` is too deep to be wrapped ([`too_deep`]). +pub(crate) fn any_too_deep(a: &[MNode]) -> bool { + a.iter().any(too_deep) +} + #[cfg(test)] mod tests { use super::*; @@ -807,6 +863,88 @@ mod tests { } } + //T1 + /// Deepest nesting of `a` (a leaf counts 1), without recursion. + fn depth_of(a: &[MNode]) -> usize { + let mut best = 0; + let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect(); + while let Some((n, d)) = stack.pop() { + best = best.max(d); + each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1)))); + } + best + } + + /// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456). + fn on_small_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap(); + } + + #[test] + fn deeply_chained_input_stays_shallow() { + on_small_stack(|| { + let cases: Vec<(&str, Arg)> = vec![ + ("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))), + ("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))), + ("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))), + ("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))), + ("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))), + ("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))), + ("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))), + ("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))), + ]; + for (name, n) in cases { + assert!(!n.is_empty(), "{name}"); + assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n)); + let copy = n.clone(); + assert_eq!(copy, n, "{name}"); + let _ = to_linear(&n); + let _ = crate::math_latex::to_latex(&n); + let json = serde_json::to_string(&n).unwrap(); + let back: Arg = serde_json::from_str(&json).unwrap(); + assert_eq!(back, n, "{name}"); + } + }); + } + + #[test] + fn ordinary_nesting_is_still_built() { + // Well below the cap: scripts, fractions and operators still wrap. + let n = parse_linear(&format!("x{}", "^1".repeat(10))); + assert_eq!(depth_of(&n), 11, "{n:#?}"); + let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}"); + assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}"); + } + + //T2 + #[test] + fn latex_text_escapes_what_latex_reads_as_markup() { + for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] { + let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })]; + let tex = crate::math_latex::to_latex(&n); + assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}"); + } + let n = vec![MNode::Run(MRun::new("a\\b"))]; + assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n); + } + + //T3 + #[test] + fn latex_runs_merge_inside_every_structure() { + use crate::math_latex::parse_latex; + for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] { + assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}"); + } + let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}"); + let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") }; + assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}"); + let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}"); + let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count(); + assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}"); + assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}"); + assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}"); + } + #[test] fn hostile_linear_never_panics() { for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] { diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 7f943e81d..c7dbdfd3a 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -5,7 +5,9 @@ //! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names, //! 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, is_nary_char, math_class, merge_runs}; +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, +}; use crate::math_linear::{FUNCTION_NAMES, map_args}; use crate::math_symbols::{name_of, symbol}; @@ -109,7 +111,7 @@ pub fn parse_latex(s: &str) -> Arg { p.i += 1; } let out = merge_runs(out); - if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) { + if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) { return vec![MNode::EqArr { rows: vec![out] }]; } out @@ -147,8 +149,13 @@ impl Lp { Tok::Sup | Tok::Sub => { self.i += 1; let s = self.arg(); - let base = out.pop(); - out.push(script(base, tok == Tok::Sup, s)); + if out.last().is_some_and(too_deep) { + // Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept. + out.extend(s); + } else { + let base = out.pop(); + out.push(script(base, tok == Tok::Sup, s)); + } } Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => { self.i += 1; @@ -171,7 +178,7 @@ impl Lp { } self.depth -= 1; // Big operators take the following term as their operand. - nary_operands(out) + nary_operands(out, self.depth) } /// A command argument: a `{group}` or one token. @@ -224,10 +231,14 @@ impl Lp { } self.i += 1; let mut level = 0i32; + // The previous token was a control word (`\backslash{}`: the empty braces end its name). + let mut after_word = false; while let Some(t) = self.peek().cloned() { self.i += 1; + let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic()))); match t { Tok::Close if level == 0 => break, + Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1, Tok::Open => { level += 1; s.push('{'); @@ -515,6 +526,10 @@ impl Lp { while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() { self.i += 1; let s = self.arg(); + if any_too_deep(&e) { + e.extend(s); + continue; + } let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) }; e.push(script(base, t == Tok::Sup, s)); } @@ -629,12 +644,14 @@ fn script(base: Option, sup: bool, s: Arg) -> MNode { } /// Big operators without an operand take the atoms after them up to a relation or binary operator. -fn nary_operands(a: Arg) -> Arg { +/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand +/// (`\sum\sum\sum…` must not nest once per operator). +fn nary_operands(a: Arg, depth: usize) -> Arg { let mut out: Arg = Vec::with_capacity(a.len()); let mut it = a.into_iter().peekable(); while let Some(n) = it.next() { match n { - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() => { + MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => { let mut body = Vec::new(); while let Some(next) = it.peek() { let stop = match next { @@ -650,8 +667,14 @@ fn nary_operands(a: Arg) -> Arg { body.push(x); } } - let body = nary_operands(body); - out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: merge_runs(body) }); + let body = merge_runs(nary_operands(body, depth + 1)); + if any_too_deep(&body) { + // No room to wrap the operand: it follows the operator. + out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e }); + out.extend(body); + } else { + out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body }); + } } other => out.push(other), } @@ -725,6 +748,21 @@ fn chars(text: &str, s: &mut String) { } } +/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped. +fn text_chars(text: &str, s: &mut String) { + for c in text.chars() { + match c { + '{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => { + s.push('\\'); + s.push(c); + } + // `{}` ends the command name without adding a space (spaces count in text). + '\\' => s.push_str("\\backslash{}"), + c => s.push(c), + } + } +} + fn delim_tex(c: Option) -> String { match c { None => ".".into(), @@ -764,7 +802,7 @@ fn node(n: &MNode, s: &mut String, depth: usize) { }; s.push_str(open); if r.nor { - s.push_str(&r.text); + text_chars(&r.text, s); } else { chars(&r.text, s); } diff --git a/crates/doc/src/math_linear.rs b/crates/doc/src/math_linear.rs index 3b5aee3f0..5ba28a4a0 100644 --- a/crates/doc/src/math_linear.rs +++ b/crates/doc/src/math_linear.rs @@ -9,7 +9,7 @@ //! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing //! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`). -use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, is_nary_char, math_class, merge_runs}; +use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep}; /// Function names set upright and applied to the following operand. pub const FUNCTION_NAMES: &[&str] = &[ @@ -135,6 +135,11 @@ impl P { '/' | '⁄' | '∕' | '¦' => { self.i += 1; let start = operand_start(&out); + // A numerator that is already deep stays as it is: `/` is text then. + if out.get(start..).is_some_and(any_too_deep) { + out.push(text(c)); + continue; + } let num = strip_parens(out.drain(start..).collect()); let den = self.operand(stops); let kind = match c { @@ -156,20 +161,34 @@ impl P { } else { self.i += 1; let s = self.script_operand(stops); - let base = out.pop(); - out.push(attach_script(base, c == '^', s)); + if out.last().is_some_and(too_deep) { + out.push(text(c)); + out.extend(s); + } else { + let base = out.pop(); + out.push(attach_script(base, c == '^', s)); + } } } '┬' | '┴' => { self.i += 1; let lim = self.script_operand(stops); - let e = out.pop().map(|n| vec![n]).unwrap_or_default(); - out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim }); + if out.last().is_some_and(too_deep) { + out.push(text(c)); + out.extend(lim); + } else { + let e = out.pop().map(|n| vec![n]).unwrap_or_default(); + out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim }); + } } c if is_combining(c) => { self.i += 1; - let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default(); - out.push(MNode::Acc { chr: c, e }); + if out.last().is_some_and(too_deep) { + out.push(text(c)); + } else { + let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default(); + out.push(MNode::Acc { chr: c, e }); + } } '▒' | '\u{2061}' => self.i += 1, ' ' => { @@ -537,25 +556,33 @@ impl P { } out.pop() }; + if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) { + break; + } + // The base is already deep: the operator is text (see `too_deep`). + let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) }; + self.i += 1; + if deep { + let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() }; + out.push(text(c)); + out.extend(s); + continue; + } match c { '^' | '_' => { - self.i += 1; let s = self.script_operand(stops); let b = base(out); out.push(attach_script(b, c == '^', s)); } - '┬' | '┴' if self.no_limits == 0 => { - self.i += 1; + '┬' | '┴' => { let lim = self.script_operand(stops); let b = base(out); out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim }); } - c if is_combining(c) => { - self.i += 1; + _ => { let b = base(out); out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() }); } - _ => break, } } } diff --git a/crates/doc/src/math_symbols.rs b/crates/doc/src/math_symbols.rs index c0af4b5d0..28b3a7f83 100644 --- a/crates/doc/src/math_symbols.rs +++ b/crates/doc/src/math_symbols.rs @@ -85,6 +85,7 @@ pub const SYMBOLS: &[(&str, char)] = &[ ("vee", '∨'), ("wedge", '∧'), ("setminus", '∖'), + ("backslash", '\\'), ("wr", '≀'), ("diamond", '⋄'), ("dagger", '†'), From 1059b196c19c861cebd8f2f8c74d586845bbdddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A2n=20Minh=20Ngh=C4=A9a?= Date: Thu, 15 Oct 2026 00:19:39 +0700 Subject: [PATCH 052/199] i18n: add Vietnamese (vi) interface translation (#1458) * i18n: add Vietnamese (vi) interface translation (#1434) * vi: translate the strings main gained since the catalog was drafted Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 5 +- contributors/people.toml | 2 + crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/tests.rs | 89 +- crates/ui-egui/src/i18n/vi.tsv | 2519 ++++++++++++++++++++++++++++++ docs/localization-parity.md | 10 +- 7 files changed, 2621 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/i18n/vi.tsv diff --git a/README.md b/README.md index 2929018bb..da05ba989 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | | **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français or Български (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български or Tiếng Việt (follows the system language by default) | | **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the diff --git a/ROADMAP.md b/ROADMAP.md index 3d14d167b..9749c1fbf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -110,11 +110,12 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Japanese | ja | partial | ~95% | | German | de | partial (#574) | ~95% | | Korean | ko | none | 0% | -| Vietnamese | vi | none | 0% | +| Vietnamese | vi | partial (#1434) | ~95% | Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a -complete 2,419-entry catalog, also without proofing. +complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete +2,448-entry catalog, also without proofing. Proofing: English, Polish and Portuguese (spelling, grammar, hyphenation), chosen per run from its language. Word ships 30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md). diff --git a/contributors/people.toml b/contributors/people.toml index 5a54f37ea..43ac40f0b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -79,3 +79,5 @@ display_name = "manucarcor" [people.webdevtodayjason] display_name = "Jason Brashear" sync_github_name = false + +[people.Nghiathan13] diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 328a5c6d7..63d28e078 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -52,7 +52,7 @@ pub struct LangInfo { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 19] = [ +pub static LANGUAGES: [LangInfo; 20] = [ LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). LangInfo { @@ -137,6 +137,8 @@ pub static LANGUAGES: [LangInfo; 19] = [ LangInfo { code: "bg", name: "Български", source: include_str!("bg.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + // Vietnamese; `vi`, `vi-VN` and `vi_VN.UTF-8` resolve here. + LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index b35210c88..89ceb7344 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -926,7 +926,7 @@ fn polish_covers_every_key_of_every_other_catalog() { // 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 style names and // equation gallery Russian translates. - for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg")) { + for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi")) { let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); missing.sort(); assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); @@ -1211,3 +1211,90 @@ fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() { } assert!(!lang("bg").uses_cjk()); } + +#[test] +fn vietnamese_locales_and_saved_preference_work_without_changing_the_document() { + let vi = lang("vi"); + for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vi", "vi-Latn-VN", "vi-US"] { + assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); + } + assert_eq!(first_supported([UNSUPPORTED, "vi-VN", "en-US"]), Some(vi)); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "VI"})).unwrap(); + assert_eq!(result["effective"], "vi"); + assert_eq!(app.ui.language, "vi"); + assert!(result["available"].as_array().unwrap().iter().any(|l| l["code"] == "vi" && l["name"] == "Tiếng Việt")); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, "vi"); + assert_eq!(Lang::from_pref(&restored.language), vi); + assert_eq!(vi.name(), "Tiếng Việt"); +} + +#[test] +fn vietnamese_covers_existing_catalog_keys_with_an_explicit_preview_fallback() { + use std::collections::HashSet; + let vi = lang("vi"); + let (entries, errors) = parse_entries(vi.0.source); + assert!(errors.is_empty(), "{errors:?}"); + let vi_keys: HashSet<_> = entries.into_iter().map(|e| (e.context, e.source)).collect(); + // The short plain title fits the preview too; this is the only deliberate context fallback. + let preview_title = ("preview".to_string(), "Title".to_string()); + assert!(vi_keys.contains(&(String::new(), "Title".to_string()))); + assert_eq!(trc(vi, "preview", "Title"), "Tiêu đề"); + assert_eq!(trc(vi, "preview", "Title"), tr(vi, "Title")); + // Compare complete (context, source) keys so a contextual entry cannot hide a missing plain + // translation, or silently satisfy another context's key. + for other in Lang::all().filter(|l| *l != Lang::EN && l.code() != "vi") { + let (entries, errors) = parse_entries(other.0.source); + assert!(errors.is_empty(), "{}: {errors:?}", other.code()); + let keys: HashSet<_> = entries.into_iter().map(|e| (e.context, e.source)).collect(); + let mut missing: Vec<_> = keys.difference(&vi_keys).filter(|key| **key != preview_title).cloned().collect(); + missing.sort(); + assert!(missing.is_empty(), "vi lacks {}'s {missing:?}", other.code()); + } +} + +#[test] +fn vietnamese_translations_preserve_templates_and_count_labels() { + let vi = lang("vi"); + assert_eq!(tr(vi, "Home"), "Trang chủ"); + assert_eq!(tr(vi, "Save"), "Lưu"); + assert_eq!(tr(vi, "Spelling & Grammar"), "Chính tả & Ngữ pháp"); + assert_eq!(tr(vi, "unknown future label"), "unknown future label"); + assert_eq!(fmt(tr(vi, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Đã xuất draft-{words}.docx"); + // Count-neutral wording (`Từ: 21`): a Vietnamese noun stays unchanged after a number. + for count in [0, 1, 2, 11, 21, 101] { + let words = count.to_string(); + assert_eq!(fmt(tr(vi, "{words} words"), &[("words", &words)]), format!("Từ: {count}")); + assert_eq!(fmt(tr(vi, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Từ: 1 trên {count}")); + } +} + +#[test] +fn bundled_interface_fonts_cover_vietnamese_letters() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("vi").0.source); + assert!(errors.is_empty(), "{errors:?}"); + // Cover the catalog's Latin letters and combining marks, and all 134 non-ASCII Vietnamese + // letters even when a letter is not used by the current translations. U+1EA0..U+1EF9 contains + // the Vietnamese vowel/tone combinations; the other forms are in earlier Latin blocks. + let extra = "ÀàÁáÃãÈèÉéÌìÍíÒòÓóÕõÙùÚúÝýÂâĂăĐđÊêÔôƠơƯưĨĩŨũ"; + let chars: std::collections::HashSet = entries + .iter() + .flat_map(|e| e.translation.chars()) + .filter(|c| ('\u{00C0}'..='\u{024F}').contains(c) || ('\u{0300}'..='\u{036F}').contains(c) || ('\u{1E00}'..='\u{1EFF}').contains(c)) + .chain(extra.chars()) + .chain('\u{1EA0}'..='\u{1EF9}') + .collect(); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in &chars { + assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch:?}"); + } + } + assert!(!lang("vi").uses_cjk()); +} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv new file mode 100644 index 000000000..71a057825 --- /dev/null +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -0,0 +1,2519 @@ +# Vietnamese (vi) interface catalog. Columns, separated by a TAB: +# context English source translation +# context usually empty. `preview` words sample text inside rendered previews (the Design +# tab's style-set tiles), which must fit a small tile; it falls back to the plain entry. +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Clean room: every entry was written from the plain meaning of the English label in ordinary +# Vietnamese computing vocabulary. No Microsoft Word (or other product) localisation, string table +# or translated UI was consulted or copied, and no other catalog in this repository was used; +# add entries the same way. +# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters. +# Commands and buttons use short imperatives (Lưu, Chèn, Dán); labels use natural Vietnamese. +# A noun after a number stays unchanged (1 từ, 2 từ), so no plural forms are needed. +# Counts use count-neutral wording (`Từ: 21`). + + (no style) (không có kiểu) + (no suggestions) (không có gợi ý) + 1.5 Line Spacing Giãn dòng 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Trình xử lý văn bản miễn phí, mã nguồn mở được viết từ đầu bằng Rust.\nMột phần trong bộ ứng dụng Crafting Apps của đội ngũ ArtCraft. + About Giới thiệu + About WordCraft Giới thiệu về WordCraft + Accept Chấp nhận + Accept All Changes Chấp nhận tất cả thay đổi + Accept This Change Chấp nhận thay đổi này + Accessibility Khả năng tiếp cận + Add Thêm + Add Object to Selection Thêm đối tượng vào vùng chọn + Add Space Before Paragraph Thêm khoảng cách trước đoạn + Add space between Asian and Latin text Thêm khoảng cách giữa chữ châu Á và chữ Latin + Add space between Asian text and numbers Thêm khoảng cách giữa chữ châu Á và chữ số + Add tab stop at: Thêm mốc tab tại: + Add Text Thêm văn bản + Add a Table Thêm bảng + Add a comment Thêm nhận xét + Add to Dictionary Thêm vào từ điển + Add to Style Gallery Thêm vào thư viện kiểu + Address Block Khối địa chỉ + Address: Địa chỉ: + Address\nBlock Khối\nđịa chỉ + Adjust Điều chỉnh + Advanced Nâng cao + Advanced Find Tìm nâng cao + Advanced Find… Tìm nâng cao… + After: Sau: + Agents Tác tử + Align Căn chỉnh + Align Left Căn trái + Align Right Căn phải + Aligned at: Căn chỉnh tại: + Alignment Căn chỉnh + All Borders Tất cả viền + All Caps Viết hoa toàn bộ + All Markup Tất cả đánh dấu + All caps Viết hoa toàn bộ + Allow hanging punctuation Cho phép dấu câu treo ngoài lề + Allow Latin text to wrap in the middle of a word Cho phép ngắt dòng văn bản Latin ở giữa từ + Alt Text Văn bản thay thế + Apply Asian line-breaking rules (kinsoku) Áp dụng quy tắc ngắt dòng châu Á (kinsoku) + Apply Character Style Áp dụng kiểu ký tự + Apply Style Áp dụng kiểu + Arrange Sắp xếp + Arrange\nAll Sắp xếp\ntất cả + Arrow Mũi tên + Artistic Effects Hiệu ứng nghệ thuật + Asian Typography Kiểu chữ châu Á + Author Tác giả + AutoCorrect Options Tùy chọn tự động sửa + AutoFit Tự khớp + AutoFit Contents Tự khớp nội dung + AutoFit Window Tự khớp cửa sổ + AutoSave Tự động lưu + AutoSave saves every change to the file (needs a saved document) Tự động lưu ghi lại mọi thay đổi vào tệp (cần tài liệu đã lưu) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Tự động lưu không khả dụng trong trình duyệt: không thể ghi vào tệp của bạn. Lưu sẽ tải xuống một bản sao. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Tự động lưu chỉ hỗ trợ tệp .docx, .odt và .rtf; {name} không giữ được toàn bộ định dạng. Lưu dưới dạng .docx để bật Tự động lưu. + Balloons Bong bóng chú thích + Bullet Dấu đầu dòng + By author Theo tác giả + Changed lines: Đường thay đổi: + Click level to modify: Bấm vào cấp độ để chỉnh sửa: + Color only Chỉ màu + Define New Multilevel List Tạo danh sách đa cấp mới + Define New Multilevel List… Tạo danh sách đa cấp mới… + Deletions: Phần xóa: + Enter formatting for number: Nhập định dạng cho số: + Follow number with: Theo sau số bằng: + Formatting Định dạng + Include level number from: Bao gồm số cấp độ từ: + Ink Mực + Insertions and Deletions Phần chèn và phần xóa + Insertions: Phần chèn: + Left border Viền trái + Legal style numbering Đánh số kiểu pháp lý + Link level to style: Liên kết cấp độ với kiểu: + List Definition Định nghĩa danh sách + Nothing Không có + Number alignment: Căn lề số: + Number format Định dạng số + Number style for this level: Kiểu số cho cấp độ này: + Outside border Viền ngoài + Restart list after: Đánh số lại sau: + Right border Viền phải + Save the document to turn on AutoSave Lưu tài liệu để bật Tự động lưu + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Lưu tài liệu để bật Tự động lưu (tệp bạn chỉ mới mở sẽ không bị ghi đè cho đến khi bạn lưu) + AutoText Văn bản tự động + Automatic Tự động + Automatic ({language}) Tự động ({language}) + Automatic Table 1 (3 levels) Mục lục tự động 1 (3 cấp độ) + Automatic Table 2 (2 levels) Mục lục tự động 2 (2 cấp độ) + Automatic follows your system's language. Menus and commands change; your documents don't. Chế độ tự động dùng ngôn ngữ của hệ thống. Menu và lệnh sẽ thay đổi; tài liệu của bạn thì không. + Backspace Xóa lùi + Banded Columns Cột xen kẽ + Banded Rows Hàng xen kẽ + Base text: Văn bản cơ sở: + Before: Trước: + Bibliography Danh mục tham khảo + Binary assets added Tài nguyên nhị phân đã thêm + Binary assets removed Tài nguyên nhị phân đã xóa + Blank Trống + Blank (Three Columns) Trống (Ba cột) + Blank Page Trang trống + Blank document Tài liệu trống + Block\nAuthors Chặn\ntác giả + Bold In đậm + Bookmark Dấu trang + Bookmark name: Tên dấu trang: + Border Painter Cọ vẽ viền + Border\nPainter Cọ vẽ\nviền + Borders Đường viền + Tab stop position: Vị trí mốc tab: + Default tab stops: Mốc tab mặc định: + Tab stops to be cleared: Mốc tab cần xóa: + All Tất cả + Decimal Căn thập phân + Bar Vạch dọc + Leader Ký tự dẫn + Set Đặt + Page Border Viền trang + Apply to: Áp dụng cho: + Whole document Toàn bộ tài liệu + This section Phần này + Fill Tô nền + Setting: Thiết lập: + 3-D 3-D + Custom Tùy chỉnh + Style: Kiểu: + Dot dash Nét chấm gạch + Triple Nét ba + Between Giữa + Borders and Shading Viền và Nền + Tabs… Mốc tab… + Borders and Shading… Viền và Nền… + Bottom Border Viền dưới + Bottom Margin Lề dưới + Bottom of Page Cuối trang + Bottom: Dưới: + Box Khung + Breaks Ngắt + Bring Forward Chuyển lên trước + Bullet Library Thư viện dấu đầu dòng + Bullets Dấu đầu dòng + Calendar Lịch + Cancel Hủy + Capitalize Each Word Chữ hoa đầu mỗi từ + Captions Chú thích + Cell Margins Lề ô + Cell Size Kích cỡ ô + Cell\nMargins Lề\nô + Center Căn giữa + Change Đổi + Change Case Đổi chữ hoa/thường + Change List Level Đổi cấp danh sách + Change Picture Đổi ảnh + Change Shape Đổi hình + Changes Các thay đổi + Character Position Vị trí ký tự + Character Scale Tỉ lệ ký tự + Character Shading Tô nền ký tự + Character Spacing Khoảng cách ký tự + Characters Ký tự + Characters (no spaces) Ký tự (không tính khoảng trắng) + Characters (with spaces) Ký tự (tính cả khoảng trắng) + Chart Biểu đồ + Check Accessibility Kiểm tra khả năng tiếp cận + Check Compatibility Kiểm tra tính tương thích + Check Spelling as You Type Kiểm tra chính tả khi gõ + Check for Errors Rà soát lỗi + Check\nAccessibility Kiểm tra\nkhả năng tiếp cận + Citation Style Kiểu trích dẫn + Citations & Bibliography Trích dẫn & Danh mục tham khảo + Clear Xóa + Clear All Xóa tất cả + Clear All Formatting Xóa tất cả định dạng + Clear Character Formatting Xóa định dạng ký tự + Clear Character Style Xóa kiểu ký tự + Clear Formatting Level Xóa cấp độ định dạng + Clear Paragraph Formatting Xóa định dạng đoạn + Click a border between two cells to merge them. Esc stops. Nhấp vào đường viền giữa hai ô để gộp. Nhấn Esc để hủy. + Clipboard Bảng tạm + Clipboard Pane Ngăn bảng tạm + Clipboard Items Các mục trong bảng tạm + Paste Clipboard Item Dán mục bảng tạm + Paste All Dán tất cả + Delete Clipboard Item Xóa mục bảng tạm + Click an item to paste it. Bấm vào một mục để dán. + Nothing collected yet. Items you copy or cut appear here. Chưa có mục nào được thu thập. Các mục bạn sao chép hoặc cắt sẽ xuất hiện ở đây. + (picture or object) (hình ảnh hoặc đối tượng) + Close Đóng + Close Header and Footer Đóng đầu trang và chân trang + Collapse Selection Thu gọn vùng chọn + Color Màu + Color: Màu: + Colors Màu + Column Cột + Column Break Ngắt cột + Columns Cột + Combine Kết hợp + Commands Lệnh + Comment Nhận xét + Comments Nhận xét + Comments Pane Ngăn nhận xét + Community\nDiscord Cộng đồng\nDiscord + Compact Thu gọn + Compare So sánh + Compress Pictures Nén ảnh + Compress punctuation at the start of a line Thu gọn dấu câu ở đầu dòng + Continuous Liên tục + Convert Chuyển đổi + Convert Text to Table Chuyển văn bản thành bảng + Convert Text to Table… Chuyển văn bản thành bảng… + Convert to Text Chuyển thành văn bản + Copy Sao chép + Copy Formatting Sao chép định dạng + Corrections Hiệu chỉnh + Count words in text boxes and notes Đếm từ trong cả hộp văn bản và ghi chú + Cover Page Trang bìa + Create Tạo + Create New Style Tạo kiểu mới + Create a Style Tạo một kiểu + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Tạo dàn ý tương tác cho tài liệu của bạn.\n\nĐây là cách tuyệt vời để theo dõi vị trí hoặc nhanh chóng di chuyển nội dung.\n\nĐể bắt đầu, hãy mở thẻ Trang chủ rồi áp dụng kiểu Tiêu đề cho các đề mục trong tài liệu. + Crop Cắt xén + Cross-reference Tham chiếu chéo + Current Position Vị trí hiện tại + Current style: {style} Kiểu đang dùng: {style} + Custom Margins… Lề tùy chỉnh… + Custom Watermark Hình mờ tùy chỉnh + Custom Watermark… Hình mờ tùy chỉnh… + Customize Quick Access Toolbar Tùy chỉnh thanh công cụ truy cập nhanh + Cut Cắt + Dark Mode Chế độ tối + Dashed Nét đứt + Data Dữ liệu + Date & Time Ngày & giờ + Decrease Font Size Giảm cỡ chữ + Decrease Indent Giảm thụt lề + Default Mặc định + Define New Bullet Tạo dấu đầu dòng mới + Define New Number Format Tạo định dạng đánh số mới + Delete Xóa + Delete Cells Xóa ô + Delete Columns Xóa cột + Delete Comment Xóa nhận xét + Delete Next Word Xóa từ tiếp theo + Delete Previous Word Xóa từ phía trước + Delete Rows Xóa hàng + Delete Style Xóa kiểu + Delete Table Xóa bảng + Design Thiết kế + Diagonal Chéo + Diamond Hình thoi + Dictate Soạn bằng giọng nói + Different First Page Trang đầu khác biệt + Different Odd & Even Pages Trang chẵn & lẻ khác nhau + Direct formatting Định dạng trực tiếp + Discord Discord + Discord community Cộng đồng Discord + Display Hiển thị + Display for Review Hiển thị để xem lại + Distribute Columns Chia đều cột + Distribute letters Giãn đều ký tự + Distribute Rows Chia đều hàng + Distribute spacing Giãn đều khoảng cách + Distributed Căn đều phân tán + Do Not Show in TOC Không hiện trong mục lục + Document End Về cuối tài liệu + Document Formatting Định dạng tài liệu + Document Property Thuộc tính tài liệu + Document Start Về đầu tài liệu + Document Text Nội dung tài liệu + Document Title Tiêu đề tài liệu + Documents you open will show up here. Các tài liệu bạn mở sẽ hiển thị ở đây. + Dot-dash Nét chấm gạch + Dotted Nét chấm + Double Nét đôi + Double Spacing Giãn dòng đôi + Double Strikethrough Gạch ngang đôi + Double Underline Gạch chân đôi + Down Xuống + Draft Bản thảo + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Kéo hình chữ nhật để vẽ bảng, hoặc kẻ đường qua các ô để tách ô. Nhấn Esc để thoát. + Draw Vẽ + Draw Table Vẽ bảng + Drawing Tools Công cụ vẽ + Drawing\nCanvas Khung\nvẽ + Drop Cap Chữ lớn đầu đoạn + Drops all formatting and pastes plain text. Bỏ mọi định dạng và dán văn bản thuần. + Edit Comment Sửa nhận xét + Edit Footer Sửa chân trang + Edit Header Sửa đầu trang + Edit Recipient List Sửa danh sách người nhận + Edit\nRecipient List Sửa\ndanh sách người nhận + Edited Đã sửa + Editing Chỉnh sửa + Editing header/footer Đang chỉnh sửa đầu trang/chân trang + Editing — edit the document directly Chỉnh sửa — sửa tài liệu trực tiếp + Editor Trình biên tập + Effects Hiệu ứng + Emboss Khắc nổi + Encrypt with Password Mã hóa bằng mật khẩu + English (United States) Tiếng Anh (Mỹ) + Engrave Khắc chìm + Enter page number: Nhập số trang: + Envelopes Phong bì + Eraser Tẩy + Even Page Trang chẵn + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Mọi lệnh đều khả dụng cho tập lệnh và tác tử AI: chạy `wordcraft-cli mcp` để khởi chạy máy chủ MCP, hoặc khởi động ứng dụng với `--control ` để mở kênh điều khiển JSON. + Export Xuất + Export PDF Xuất PDF + Export Page as PNG Xuất trang dưới dạng PNG + Export a copy to share (PDF, Word document) Xuất bản sao để chia sẻ (PDF, tài liệu Word) + Exported {path} Đã xuất {path} + Extend Selection Mở rộng vùng chọn + Field Trường + File Tệp + Find Tìm + Find Next Tìm tiếp + Find Previous Tìm trước đó + Find Recipient Tìm người nhận + Find and Replace Tìm và Thay thế + Find what: Tìm gì: + Finish Hoàn tất + Finish & Merge Hoàn tất & Trộn thư + Finish &\nMerge Hoàn tất &\nTrộn thư + First Column Cột đầu tiên + First commit Lần commit đầu tiên + First line: Dòng đầu: + Fixed Column Width Cố định độ rộng cột + Focus Tập trung + Font Phông chữ + Font Color Màu phông chữ + Font Dialog Hộp thoại phông chữ + Font Size Cỡ chữ + Font color: Màu phông chữ: + Font: Phông chữ: + Fonts Phông chữ + Footer Chân trang + Footnote and Endnote Chú thích chân trang và chú thích cuối tài liệu + Footnotes Chú thích chân trang + Format Định dạng + Format Page Numbers Định dạng số trang + Format Page Numbers… Định dạng số trang… + Format Painter Sao chép định dạng + Formatted Text Văn bản có định dạng + Formatting at Selection Định dạng tại vùng chọn + Formula Công thức + General Chung + Go To Đi tới + Go To (⌘⌥G) Đi tới (⌘⌥G) + Go To… Đi tới… + Go to Đi tới + Grab bag Hỗn hợp + Greeting Line Dòng lời chào + Greeting\nLine Dòng\nlời chào + Gridlines Đường lưới + Group Gộp nhóm + Grow Font 1 Point Tăng cỡ chữ 1 pt + HTML Format Định dạng HTML + Hanging Indent Thụt lề treo + Header Đầu trang + Header & Footer Đầu trang & Chân trang + Header Row Hàng tiêu đề + Header/Footer Position Vị trí đầu trang/chân trang + Heading 1 Tiêu đề 1 + Heading 2 Tiêu đề 2 + Heading 3 Tiêu đề 3 + Headings Tiêu đề + Heart Hình trái tim + Help Trợ giúp + Hidden Ẩn + Highlight Merge Fields Làm nổi bật trường trộn + Highlight\nMerge Fields Làm nổi bật\ntrường trộn + Highlighter Bút tô sáng + Home Trang chủ + Horizontal Ngang + Horizontal Line Đường kẻ ngang + Hyphenation Gạch nối + Icons Biểu tượng + Ignore All Bỏ qua tất cả + Illustrations Hình minh họa + Immersive Tập trung + Immersive Reader Bộ đọc tập trung + Immersive\nReader Bộ đọc\ntập trung + Increase Font Size Tăng cỡ chữ + Increase Indent Tăng thụt lề + Indent Thụt lề + Indentation Thụt lề + Indents Thụt lề + Indents and Spacing Thụt lề và giãn cách + Index Chỉ mục + Info Thông tin + Ink to\nMath Mực thành\ncông thức + Ink to\nShape Mực thành\nhình + Ink\nReplay Phát lại\nmực + Insert Chèn + Insert Above Chèn lên trên + Insert Below Chèn xuống dưới + Insert Caption Chèn chú thích + Insert Citation Chèn trích dẫn + Insert Endnote Chèn chú thích cuối tài liệu + Insert Footnote Chèn chú thích chân trang + Insert Hyperlink Chèn siêu liên kết + Insert Index Chèn chỉ mục + Insert Left Chèn sang trái + Insert Merge Field Chèn trường trộn thư + Insert Merge\nField Chèn trường\ntrộn thư + Insert Right Chèn sang phải + Insert Shapes Chèn hình + Insert Table Chèn bảng + Insert Table of Authorities Chèn bảng dẫn nguồn + Insert Table of Figures Chèn danh mục hình + Insert Table… Chèn bảng… + Insert\nAbove Chèn\nlên trên + Insert\nCaption Chèn\nchú thích + Insert\nCitation Chèn\ntrích dẫn + Insert\nFootnote Chèn chú thích\nchân trang + Inside Borders Viền trong + Inspect Document Kiểm tra tài liệu + Inspect Styles at Selection Kiểm tra kiểu tại vùng chọn + Interface language: Ngôn ngữ giao diện: + Italic In nghiêng + Join the ArtCraft community on Discord Tham gia cộng đồng ArtCraft trên Discord + Join us on Discord: discord.gg/artcraft Tham gia cùng chúng tôi trên Discord: discord.gg/artcraft + Justify Căn đều + Keep Lines Together Giữ các dòng cùng nhau + Keep Text Only Chỉ giữ văn bản + Keep lines together Giữ các dòng cùng nhau + Keep with Next Giữ cùng đoạn kế tiếp + Keep with next Giữ cùng đoạn kế tiếp + Keeps the fonts and formatting of the copied text. Giữ nguyên phông chữ và định dạng của văn bản đã sao chép. + Labels Nhãn + Landscape Hướng ngang + Language Ngôn ngữ + Lasso Khoanh vùng + Last Column Cột cuối cùng + Last commit Lần commit cuối cùng + Layout Bố trí + Layout Summary Tóm tắt bố cục + Left Trái + Left Border Viền trái + Left Margin Lề trái + Left: Trái: + Letter Thư + Line Đường thẳng + Line Break Ngắt dòng + Line breaking Ngắt dòng + Line End Cuối dòng + Line Numbers Đánh số dòng + Line Spacing Giãn dòng + Line Spacing Options… Tùy chọn giãn dòng… + Line Start Đầu dòng + Line and Page Breaks Ngắt dòng và trang + Line and Paragraph Spacing Khoảng cách dòng và đoạn + Line delta Chênh lệch số dòng + Line spacing: Giãn dòng: + Lines Số dòng + Lines added Dòng thêm vào + Lines deleted Dòng đã xóa + Link Liên kết + Link to Previous Liên kết với phần trước + Links Liên kết + Location: {path} Vị trí: {path} + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Bản quyền (c) 2026 ArtCraft Team và các cộng tác viên WordCraft. + Macros Macro + Mailings Gửi thư + Manage Sources Quản lý nguồn + Many pages Nhiều trang + Margins Lề + Mark Citation Đánh dấu trích dẫn + Mark Entry Đánh dấu mục + Mark\nCitation Đánh dấu\ntrích dẫn + Mark\nEntry Đánh dấu\nmục + Markdown (*.md) Markdown (*.md) + Match Fields Khớp trường + Match case Khớp chữ hoa/thường + Matrix Ma trận + Merge Gộp + Merge Cells Gộp ô + Merge Formatting Hòa trộn định dạng + Merged PRs PR đã gộp + Mirrored Phản chiếu + Moderate Vừa phải + Modify Style Sửa đổi kiểu + Modify… Sửa đổi… + 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 + Multiple Pages Nhiều trang + Name (A–Z) Tên (A–Z) + Name: Tên: + Narrow 0.5" all Hẹp 0,5" mọi cạnh + Navigation Điều hướng + Navigation Pane Ngăn điều hướng + New Mới + New Comment Nhận xét mới + New Paragraph Đoạn mới + New Style… Kiểu mới… + New Window Cửa sổ mới + New from Template Tạo mới từ mẫu + New\nComment Nhận xét\nmới + New\nWindow Cửa sổ\nmới + Next Tiếp theo + Next Change Thay đổi tiếp theo + Next Comment Nhận xét tiếp theo + Next Footnote Chú thích chân trang tiếp theo + Next Page Trang tiếp theo + Next Record Bản ghi tiếp theo + Next Section Phần tiếp theo + No Border Không viền + No Color Không màu + No Markup Không có đánh dấu + No Paragraph Space Không giãn cách đoạn + No contributor data was built into this copy. Bản sao này không tích hợp sẵn dữ liệu người đóng góp. + No model credits were built into this copy. Bản sao này không tích hợp sẵn thông tin ghi công mô hình. + No templates match your search. Không có mẫu nào khớp với tìm kiếm của bạn. + Nonbreaking Hyphen Gạch nối không ngắt dòng + Nonbreaking Space Khoảng trắng không ngắt dòng + None Không có + Normal 1" all Thường 1" mọi cạnh + Normal Style Kiểu Thường + Not available right now Hiện không khả dụng + Not saved Chưa lưu + Not saved yet Chưa lưu + Nudge Dịch chuyển nhỏ + Number of columns: Số cột: + Number of rows: Số hàng: + Numbering Đánh số + Offset (pt): Độ lệch (pt): + OK OK + Object Đối tượng + Odd Page Trang lẻ + Office 2003 Default Mặc định Office 2003 + One Một + One Page Một trang + Open Mở + OpenDocument Text (*.odt) Tệp văn bản OpenDocument (*.odt) + Optional Hyphen Gạch nối tùy chọn + Copyright Sign Ký hiệu bản quyền + Ellipsis Dấu ba chấm + Em Dash Gạch ngang dài + Insert Date Field Chèn trường ngày + Insert Page Field Chèn trường số trang + Insert Time Field Chèn trường giờ + Registered Sign Ký hiệu đã đăng ký + Reset Character Formatting Đặt lại định dạng ký tự + Reset Paragraph Formatting Đặt lại định dạng đoạn + Trademark Sign Ký hiệu nhãn hiệu + Options Tùy chọn + Orientation Hướng trang + Outline Dàn ý + Outline Level Cấp dàn ý + Outside Borders Viền ngoài + Oval Hình bầu dục + PDF document (*.pdf) Tài liệu PDF (*.pdf) + Page Trang + Page Background Nền trang + Page Borders Viền trang + Page Break Ngắt trang + Page Break Before Ngắt trang trước đoạn + Page Breaks Ngắt trang + Page Color Màu nền trang + Page Down Chuyển xuống một trang + Page Movement Cách chuyển trang + Page Number Số trang + Page Setup Thiết lập trang + Page Up Chuyển lên một trang + Page Width Độ rộng trang + Page X of Y Trang X trên Y + Page break before Ngắt trang trước đoạn + Page image (*.png) Ảnh trang (*.png) + Page width Độ rộng trang + Page {page} Trang {page} + Page {page} of {pages} Trang {page} trên {pages} + Page\nBorders Viền\ntrang + Page\nColor Màu nền\ntrang + Page\nNumber Số\ntrang + Pages Trang + Paragraph Đoạn + Paragraph Details Chi tiết đoạn + Paragraph Down Đưa con trỏ xuống một đoạn + Paragraph Settings Thiết lập đoạn + Paragraph Spacing Khoảng cách đoạn + Paragraph Up Đưa con trỏ lên một đoạn + Paragraphs Các đoạn + Paste Dán + Paste (⌘V) Dán (⌘V) + Paste Formatting Dán định dạng + Paste Special Dán đặc biệt + Paste Special… Dán đặc biệt… + Paste as: Dán dưới dạng: + Paste: Keep Text Only Dán: chỉ giữ văn bản + Paste: Merge Formatting Dán: hợp nhất định dạng + Pen Bút + Pencil Bút chì + Phonetic Guide Hướng dẫn phát âm + Picture Border Viền ảnh + Picture Format Định dạng ảnh + Picture Styles Kiểu ảnh + Pictures Ảnh + Plain text (*.txt) Văn bản thuần (*.txt) + Portrait Nằm dọc + Position Vị trí + Preview Results Xem thử kết quả + Preview\nResults Xem thử\nkết quả + Previous Trước đó + Previous Change Thay đổi trước đó + Previous Comment Nhận xét trước đó + Previous Record Bản ghi trước đó + Previous Section Phần trước đó + Print In + Print Layout Bố cục in + Print\nLayout Bố cục\nin + Proofing Hiệu đính + Proofing Issues Lỗi cần hiệu đính + Properties Thuộc tính + Protect Bảo vệ + Protect Document Bảo vệ tài liệu + Quick Access Toolbar Thanh công cụ truy cập nhanh + Quick Parts Thành phần nhanh + Quick Tables Bảng nhanh + Read Aloud Đọc to + Read Mode Chế độ đọc + Read\nAloud Đọc\nto + Read\nMode Chế độ\nđọc + Reads the clipboard's rich text and keeps its formatting. Đọc văn bản có định dạng từ bảng tạm và giữ nguyên định dạng. + Reads the clipboard's web content and keeps its formatting. Đọc nội dung web từ bảng tạm và giữ nguyên định dạng. + Reading Highlight Làm nổi bật khi đọc + Recent Gần đây + Record Macro Ghi macro + Recover Unsaved Documents Khôi phục tài liệu chưa lưu + Rectangle Hình chữ nhật + Redo Làm lại + Reduce Hanging Indent Giảm thụt lề treo + References Tham chiếu + Reject Từ chối + Reject All Changes Từ chối mọi thay đổi + Reject Change Từ chối thay đổi + Relaxed Giãn rộng + Remove Gỡ bỏ + Remove Background Gỡ nền ảnh + Remove Footer Gỡ chân trang + Remove Header Gỡ đầu trang + Remove Hyperlink Gỡ siêu liên kết + Remove Space After Paragraph Xóa khoảng cách sau đoạn + Remove Table of Contents Xóa mục lục + Remove Watermark Xóa hình mờ + Reopen Mở lại + Repeat Lặp lại + Repeat Header Rows Lặp lại hàng tiêu đề + Replace Thay thế + Replace All Thay thế tất cả + Replace Document Text Thay toàn bộ văn bản tài liệu + Replace with: Thay bằng: + Replay Phát lại + Reply Trả lời + Report Báo cáo + Research Tra cứu + Researcher Công cụ tra cứu + Reset Picture Đặt lại ảnh + Reset to Normal Đặt lại về kiểu Thường + Resolve Giải quyết + Resolved Đã giải quyết + Restart Each Page Đánh số lại từ đầu mỗi trang + Restart Each Section Đánh số lại từ đầu mỗi phần + Restart at 1 Bắt đầu lại từ 1 + Restrict Editing Hạn chế chỉnh sửa + Restrict\nEditing Hạn chế\nchỉnh sửa + Result Kết quả + Results Các kết quả + Reverse the order Đảo ngược thứ tự + Reviewing Góp ý + Reviewing Pane Khung theo dõi thay đổi + Reviewing — edits become suggestions Góp ý — các chỉnh sửa trở thành đề xuất + Rich Text Format (*.rtf) Rich Text Format (*.rtf) + Rich Text Format (RTF) Rich Text Format (RTF) + Right Phải + Right (vertical) Phải (dọc) + Right Border Viền bên phải + Right Margin Lề phải + Right-to-Left Text Direction Hướng chữ từ phải sang trái + Left-to-Right Text Direction Hướng chữ từ trái sang phải + Right: Phải: + Rotate Xoay + Rounded Rectangle Hình chữ nhật bo góc + Rows & Columns Hàng & Cột + Ruby text: Chữ phiên âm: + Ruler Thước + Rules Quy tắc + Résumé CV + Save Lưu + Save As Lưu thành + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Lưu một bản sao ở định dạng khác. Việc in sử dụng tệp PDF mà bạn có thể in bằng bất kỳ trình xem PDF nào. + Save as Template Lưu làm mẫu + Saved Đã lưu + Search Commands Tìm lệnh + Search commands and help Tìm lệnh và trợ giúp + Search document Tìm trong tài liệu + Search for templates Tìm mẫu + Section Breaks Ngắt phần + Section Properties Thuộc tính phần + Sections Các phần + Select Chọn + Select All Chọn tất cả + Select Cell Chọn ô + Select Line Chọn dòng + Select Objects Chọn đối tượng + Select Paragraph Chọn đoạn + Select Range Chọn phạm vi + Select Recipients Chọn người nhận + Select Row Chọn hàng + Select Sentence Chọn câu + Select Table Chọn bảng + Select Text Chọn văn bản + Select Text with Similar Formatting Chọn văn bản có định dạng tương tự + Select Word Chọn từ + Select\nRecipients Chọn\nngười nhận + Selection Phần đã chọn + Selection Pane Khung chọn đối tượng + Send Backward Chuyển ra sau + Sentence case. Viết hoa đầu câu. + Set Caret Đặt con trỏ + Set Character Formatting Đặt định dạng ký tự + Set Numbering Value Đặt giá trị đánh số + Set Paragraph Formatting Đặt định dạng đoạn + Set as Default Đặt làm mặc định + Shading Tô nền + Shadow Bóng đổ + Shape Fill Tô nền hình + Shape Format Định dạng hình + Shape Outline Viền hình + Shape Styles Kiểu hình + Shapes Các hình + Share Chia sẻ + Shift+Tab Shift+Tab + Show Hiện + Show all revisions inline Hiện mọi sửa đổi trong văn bản + Show Comments Hiện nhận xét + Show Markup Hiện đánh dấu + Show Notes Hiện chú thích + Show all formatting marks Hiện toàn bộ dấu định dạng + Show only comments and formatting in balloons Chỉ hiện nhận xét và định dạng trong ô bên lề + Show revisions in balloons Hiện các sửa đổi trong ô bên lề + Show rulers Hiện thước + Show the community button in the title bar Hiện nút cộng đồng trong thanh tiêu đề + Show/Hide ¶ Hiện/Ẩn ¶ + Show\nComments Hiện\nnhận xét + Shrink Font 1 Point Giảm cỡ phông chữ 1 pt + Side to Side Xem cạnh nhau + Side\nto Side Xem\ncạnh nhau + Signature Line Dòng chữ ký + Single Đơn + Single Spacing Giãn dòng đơn + Size Cỡ + Size (pt): Cỡ (pt): + Size: Cỡ: + Small Caps Chữ hoa nhỏ + Small caps Chữ hoa nhỏ + SmartArt SmartArt + Sort Sắp xếp + Source code: github.com/storytold/wordcraft Mã nguồn: github.com/storytold/wordcraft + Space Dấu cách + Space before/after: Khoảng cách trước/sau: + Spacing Khoảng cách + Spacing (pt): Khoảng cách (pt): + Speech Giọng nói + Spelling & Grammar Chính tả & Ngữ pháp + Spelling &\nGrammar Chính tả &\nngữ pháp + Spelling Suggestions Gợi ý chính tả + Split Chia cửa sổ + Split Cells Chia ô + Split Table Chia bảng + Spreadsheet Table Bảng tính + Standard Colors Màu chuẩn + Star Hình sao + Start at: Bắt đầu từ: + Start Mail Merge Bắt đầu trộn thư + Start Mail\nMerge Bắt đầu\ntrộn thư + Statistics Thống kê + Status Bar Thanh trạng thái + Strikethrough Gạch ngang chữ + Studio cover Bìa Studio + Studio handbook (sample) Sổ tay Studio (mẫu) + Style Kiểu + Style Inspector Trình kiểm tra kiểu + Style Set Bộ kiểu + Style based on: Kiểu dựa trên: + Style: {style} Kiểu: {style} + Styles Kiểu + Styles Pane Khung kiểu + Subscript Chỉ số dưới + Superscript Chỉ số trên + Switch Modes Chuyển chế độ + Switch\nModes Chuyển\nchế độ + Symbol Ký hiệu + Symbols Các ký hiệu + Synonyms Từ đồng nghĩa + Tab Tab + Tab character Ký tự tab + Table Bảng + Table Column Width Độ rộng cột bảng + Table Design Thiết kế bảng + Table Layout Bố trí bảng + Table Properties Thuộc tính bảng + Table Row Height Chiều cao hàng trong bảng + Table Style Options Tùy chọn kiểu bảng + Table Styles Các kiểu bảng + Table of Authorities Bảng dẫn nguồn + Table of Contents Mục lục + Table of\nContents Mục\nlục + Tables Bảng + Tabs Điểm dừng tab + Tabular List Danh sách dạng bảng + Text Văn bản + Text Box Hộp văn bản + Text Direction Hướng văn bản + Text Effects Hiệu ứng chữ + Text Effects and Typography Hiệu ứng chữ và kiểu chữ + Text Highlight Color Màu tô sáng chữ + Text indent at: Vị trí thụt lề văn bản: + Text Wrapping Ngắt dòng văn bản + Text from File Văn bản từ tệp + Text to display: Văn bản hiển thị: + Text: Văn bản: + Text\nBox Hộp\nvăn bản + Text\nDirection Hướng\nvăn bản + The clipboard is empty. Bảng tạm đang trống. + The new style takes the formatting of the current paragraph. Kiểu mới sẽ lấy định dạng của đoạn hiện tại. + Theme Colors Màu chủ đề + Theme Fonts Phông chữ chủ đề + Theme List Danh sách chủ đề + Themes Chủ đề + Thesaurus Từ điển đồng nghĩa + Thick Dày + Three Ba + Tight Khít + Top Border Viền trên + Top Margin Lề trên + Top of Page Đầu trang + Top: Trên: + Total Row Hàng tổng + Track Changes Theo dõi thay đổi + Track Changes Options Tùy chọn theo dõi thay đổi + Track Changes: On Theo dõi thay đổi: Bật + Track formatting Theo dõi thay đổi định dạng + Tracking Theo dõi + Translate Dịch + Transparency Độ trong suốt + Triangle Tam giác + Two Hai + Type Text Nhập văn bản + Type a command, e.g. "insert table" Nhập lệnh, ví dụ "chèn bảng" + Ungroup Tách nhóm + UPPERCASE CHỮ HOA + Underline Gạch chân + Underline Words Only Chỉ gạch chân từng từ + Undo Hoàn tác + Undo {action} Hoàn tác {action} + Unformatted Text Văn bản không định dạng + Up Lên + Update Field Cập nhật trường + Update Index Cập nhật chỉ mục + Update Style to Match Selection Cập nhật kiểu theo vùng chọn + Update Table Cập nhật bảng + Update Table of Figures Cập nhật danh mục hình + Update to Match Cập nhật theo vùng chọn + Update to Match Selection Cập nhật theo vùng chọn + User Name Tên người dùng + User name: Tên người dùng: + Version History Lịch sử phiên bản + Version {version} ({build}) Phiên bản {version} ({build}) + Vertical Dọc + Vertical Alignment Căn dọc + View Xem + View Gridlines Hiện đường lưới bảng + View State Trạng thái hiển thị + Views Các chế độ xem + Voice Giọng nói + Watermark Hình mờ + Wave Lượn sóng + Web Layout Bố cục web + Web\nLayout Bố cục\nweb + Web page (*.html) Trang web (*.html) + Whole page Cả trang + Whole words Chỉ tìm nguyên từ + Wide Rộng + Widow/Orphan Control Tránh dòng đơn lẻ ở đầu/cuối trang + Widow/Orphan control Tránh dòng đơn lẻ ở đầu/cuối trang + Wildcards (regex) Ký tự đại diện (regex) + Window Cửa sổ + Word Count Đếm từ + Word Left Đưa con trỏ sang trái một từ + Word Right Đưa con trỏ sang phải một từ + Word document (*.docx) Tài liệu Word (*.docx) + WordArt WordArt + WordCraft WordCraft + Words Từ + Words only Chỉ gạch chân từ + Wrap Text Văn bản quanh đối tượng + Wrap\nText Văn bản\nquanh đối tượng + Write & Insert Fields Soạn và chèn trường + Write a reply Viết câu trả lời + Zoom Thu phóng + Zoom In Phóng to + Zoom Out Thu nhỏ + development build bản phát triển + lowercase viết thường + {group} settings Thiết lập {group} + {key}+Click to follow link {key}+Nhấp để mở liên kết + {selected} of {words} words Từ: {selected} trên {words} + {words} words Từ: {words} + Browse… Duyệt… + Equation Phương trình + Review Xem lại + Track\nChanges Theo dõi\nthay đổi + Track\nChanges ✓ Theo dõi\nthay đổi ✓ + Scroll ribbon left Cuộn dải lệnh sang trái + Scroll ribbon right Cuộn dải lệnh sang phải + Apply Styles Áp dụng kiểu + Dark page (white text on black) Trang tối (chữ trắng trên nền đen) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Hiện tài liệu với màu đảo ngược như Xem › Chuyển chế độ. Việc lưu, in và xuất PDF không bị ảnh hưởng. + Only web (http, https) and email (mailto) links can be followed Chỉ mở được liên kết web (http, https) và email (mailto) + Character Border Viền ký tự + Direction: Hướng: + Right-to-left Từ phải sang trái + Left-to-right Từ trái sang phải + Zotero Zotero + Citations Trích dẫn + Document Tài liệu + Add/Edit\nCitation Thêm/Sửa\ntrích dẫn + Add\nNote Thêm\nghi chú + Add/Edit\nBibliography Thêm/Sửa\ndanh mục tham khảo + Refresh Làm mới + Document\nPreferences Tùy chọn\ntài liệu + Unlink\nCitations Gỡ liên kết\ntrích dẫn + Waiting for Zotero… Đang chờ Zotero… + Zotero: done Zotero: hoàn tất + Zotero: cancelled Zotero: đã hủy + Yes Có + No Không + Move Past Citation Đưa con trỏ ra sau trích dẫn + Move Past\nCitation Ra sau\ntrích dẫn + Next Sentence Câu tiếp theo + Previous Sentence Câu trước đó + Next sentence Câu tiếp theo + Previous sentence Câu trước đó + Play/Pause Phát/Tạm dừng + Read Aloud Status Trạng thái đọc to + Reading Speed Tốc độ đọc + Skip Citations and Bibliography Bỏ qua trích dẫn và danh mục tham khảo + Skip citations & bibliography Bỏ qua trích dẫn và danh mục tham khảo + Stop Reading Dừng đọc + Don't read Zotero, Mendeley or EndNote citations and bibliographies Không đọc trích dẫn và danh mục tham khảo do Zotero, Mendeley hoặc EndNote quản lý + Play Phát + Pause Tạm dừng + Stop Dừng + Sentence Câu + Paused Đã tạm dừng + Finished Đã đọc xong + Stopped Đã dừng + Speed Tốc độ + Normal speed Tốc độ bình thường + AutoSave documents you have saved in WordCraft Tự động lưu các tài liệu bạn đã lưu trong WordCraft + AutoSave failed: {error}. Save the document to turn AutoSave back on. Không thể tự động lưu: {error}. Hãy lưu tài liệu để bật lại tính năng này. + Couldn't download {name}: {error} Không tải được {name}: {error} + Do you want to save changes to {name}? Bạn có muốn lưu các thay đổi vào {name}? + Don't Save Không lưu + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Định dạng đó không giữ được toàn bộ thông tin của tài liệu, nên tài liệu vẫn mở. Hãy lưu dưới dạng tài liệu Word (.docx) để tiếp tục. + Your changes will be lost if you don't save them. Các thay đổi sẽ bị mất nếu bạn không lưu. + Change Structure Đổi cấu trúc + Change to Display Chuyển ra dòng riêng + Change to Inline Chuyển vào dòng văn bản + Conversions Chuyển đổi + Delete Back in Equation Xóa lùi trong phương trình + Delete in Equation Xóa trong phương trình + Edit Equation Sửa phương trình + Enter in Equation Nhấn Enter trong phương trình + Equation Gallery Thư viện phương trình + Equation Info Thông tin phương trình + Equation Number Đánh số phương trình + Input Format Định dạng nhập + Insert Structure Chèn cấu trúc + Insert Symbol Chèn ký hiệu + Justification Căn chỉnh + Leave Equation Rời khỏi phương trình + Move in Equation Di chuyển trong phương trình + Normal Text Văn bản thường + Set Equation Đặt phương trình + Structure Actions Thao tác cấu trúc + Structures Cấu trúc + Tools Công cụ + Type in Equation Gõ trong phương trình + Inline Trong dòng + Number Đánh số + Close Equation Đóng phương trình + Insert New Equation Chèn phương trình mới + Built-In Có sẵn + Professional Chuyển sang dạng chuyên nghiệp + Linear Chuyển sang dạng tuyến tính + Fraction Phân số + Script Chỉ số + Radical Dấu căn + Integral Dấu tích phân + Large\nOperator Toán tử\ncỡ lớn + Bracket Dấu ngoặc + Function Hàm + Accent Dấu + Limit and\nLog Giới hạn và\nlogarit + Operator Toán tử + Equation Options Tùy chọn phương trình + All symbol sets Tất cả các bộ ký hiệu + Send the document straight to the system print dialog, or save a copy in another format below. Gửi tài liệu trực tiếp tới hộp thoại in của hệ thống, hoặc lưu bản sao ở định dạng khác bên dưới. + Or save a copy in another format: Hoặc lưu bản sao ở định dạng khác: + Opening print dialog… Đang mở hộp thoại in… + Print failed: {error} Không in được: {error} + Couldn't open {path}: {error} Không mở được {path}: {error} + Column Selection Chọn khối theo cột + New Table Style Kiểu bảng mới + Modify Table Style Sửa đổi kiểu bảng + New Table Style… Kiểu bảng mới… + Modify Table Style… Sửa đổi kiểu bảng… + Apply formatting to: Áp dụng định dạng cho: + Whole Table Cả bảng + Fill color: Màu tô: + {count} items highlighted {count} mục được làm nổi bật + {n} pt {n} pt + Not {name} Không phải {name} + Level {n} Cấp độ {n} + Body Text Văn bản nội dung + {n} lines {n} dòng + At least {n} Ít nhất {n} + Exactly {n} Chính xác {n} + Centered Căn giữa + Character spacing Khoảng cách ký tự + Don't add space between paragraphs of the same style Không thêm khoảng cách giữa các đoạn cùng kiểu + Don't hyphenate Không ngắt từ + Double strikethrough Gạch ngang đôi + Drop cap Chữ lớn đầu đoạn + First line Dòng đầu + Font color Màu phông chữ + Highlight Tô sáng + Justified Căn đều + Left indent Thụt lề trái + Line spacing Giãn dòng + Outline level Cấp dàn ý + Raised/lowered Nâng lên/hạ xuống + Right indent Thụt lề phải + Scale Tỉ lệ + Space after Khoảng cách sau + Space before Khoảng cách trước + Suppress line numbers Không đánh số dòng + Underline color Màu gạch chân + Letters Thư + E-mail Messages Thư điện tử + Directory Danh mục + Normal Word Document Tài liệu Word thông thường + If…Then…Else… Nếu…Thì…Không thì… + If…Then…Else Nếu…Thì…Không thì + Merge Record # Bản ghi trộn # + Skip Record If… Bỏ qua bản ghi nếu… + Skip Record If Bỏ qua bản ghi nếu + Equals: Bằng: + Then insert: Thì chèn: + Otherwise insert: Nếu không thì chèn: + Courtesy Title Danh xưng + First Name Tên + Last Name Họ + Company Công ty + Address Địa chỉ + City Thành phố + State Tiểu bang + Postal Code Mã bưu chính + Country Quốc gia + (not matched) (không khớp) + Leave a recipient out of the merge when a field has this value. Bỏ một người nhận khỏi trộn thư khi một trường có giá trị này. + Insert one text or another, depending on a field's value. Chèn văn bản này hoặc văn bản khác, tùy theo giá trị của một trường. + Select recipients first to match their fields. Chọn người nhận trước để khớp các trường của họ. + The recipient-list field that fills each part of an address block and greeting line. Trường danh sách người nhận điền vào từng phần của khối địa chỉ và dòng chào hỏi. + No errors found: every merge field is in the recipient list. Không tìm thấy lỗi: mọi trường trộn đều có trong danh sách người nhận. + These merge fields aren't in the recipient list: Các trường trộn này không nằm trong danh sách người nhận: + Add at least one field. Thêm ít nhất một trường. + Add Field Thêm trường + Every field needs a name. Mỗi trường đều cần có tên. + Field name Tên trường + Field names must be different. Tên các trường phải khác nhau. + Fields: Các trường: + Field: Trường: + Find: Tìm: + New Entry Mục mới + Recipient List Danh sách người nhận + Recipients: {count} Người nhận: {count} + Remove field Gỡ trường + Remove recipient Gỡ người nhận + Select recipients first to choose from their fields. Chọn người nhận trước để chọn từ các trường của họ. + Type a New List… Nhập danh sách mới… + Type at least one recipient. Nhập ít nhất một người nhận. + Type the recipients, or load them from a file. Nhập người nhận, hoặc tải từ tệp. + Use an Existing List… Dùng danh sách hiện có… + Choose the sheet that holds the recipients. Chọn trang tính chứa danh sách người nhận. + First row of data contains column headers Hàng đầu tiên của dữ liệu chứa tiêu đề cột + Interface theme: Chủ đề giao diện: + Interface\nTheme Chủ đề\ngiao diện + Interface Theme Chủ đề giao diện + Light Sáng + Dark Tối + Use system setting Dùng theo hệ thống + A file dialog is already open. Đã có một hộp thoại tệp đang mở. + Height: Chiều cao: + Width: Chiều rộng: + Row Hàng + Cell Ô + Preferred width: Độ rộng ưu tiên: + Alignment: Căn chỉnh: + Indent from left: Thụt lề từ trái: + Specify height: Chỉ định chiều cao: + Exactly Chính xác + At least Tối thiểu + Allow row to break across pages Cho phép ngắt hàng sang trang sau + Repeat as header row on each page Lặp lại làm hàng tiêu đề trên mỗi trang + Vertical alignment: Căn chỉnh dọc: + Top Trên + Bottom Dưới + Alt text Văn bản thay thế + Alt: Alt: + W: Rộng: + H: Cao: + Reset Đặt lại + Reset Crop Đặt lại cắt xén + Insert a new equation (Alt+=) Chèn phương trình mới (Alt+=) + Leave the equation (Esc) Rời khỏi phương trình (Esc) + Preview Xem thử + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Vẽ mực + Erase Ink Xóa mực + Hide Ink Ẩn mực + Hide\nInk Ẩn\nmực + Pens Bút + Thickness Độ dày + (All) (Tất cả) + (none) (không có) + (normal text) (văn bản thường) + 1/4 Em Space Khoảng trắng 1/4 em + A mail merge field Một trường trộn thư + A numbered sequence (figures, tables…) Một dãy đánh số thứ tự (hình, bảng…) + A table of contents Một mục lục + Categories: Hạng mục: + Character Ký tự + Character code: Mã ký tự: + Col #: Cột #: + Copyright Bản quyền + Date and Time Ngày và giờ + Date formats: Định dạng ngày: + Description: Mô tả: + Document Information Thông tin tài liệu + Double Closing Quote Dấu nháy kép đóng + Double Opening Quote Dấu nháy kép mở + En Dash Gạch ngang ngắn + Em Space Khoảng trắng em + En Space Khoảng trắng en + Equal column width Độ rộng cột bằng nhau + Field Codes Mã trường + Field code: Mã trường: + Field codes: Mã các trường: + Field name: Tên trường: + Field names: Tên các trường: + Field properties Thuộc tính trường + Field… Trường… + Format: Định dạng: + Hide Codes Ẩn mã + Index and Tables Chỉ mục và mục lục + Line between Đường kẻ giữa + Links and References Liên kết và tham chiếu + Mail Merge Trộn thư + More Columns… Thêm cột… + More Symbols… Thêm ký hiệu… + No-Width Non Break Không ngắt không độ rộng + No-Width Optional Break Ngắt tùy ý không độ rộng + Paragraph Mark Ký hiệu đoạn + Pick a field from the list Chọn một trường từ danh sách. + Preserve formatting during updates Giữ định dạng khi cập nhật + Presets Mẫu đặt sẵn + Recently used symbols: Ký hiệu dùng gần đây: + Registered Nhãn hiệu đã đăng ký + Section Mark Ký hiệu phần + Sequence name: Tên dãy: + Shortcut key: Phím tắt: + Single Closing Quote Dấu nháy đơn đóng + Single Opening Quote Dấu nháy đơn mở + Spacing: Khoảng cách: + Special Characters Ký tự đặc biệt + Subset: Tập con: + The time right now Giờ hiện tại + When this document was first created Ngày tạo tài liệu + When this document was most recently printed Ngày in gần nhất + When this document was most recently saved Ngày lưu gần nhất + The document has no bookmarks yet. Tài liệu chưa có dấu trang. + Who the document properties name as author Tác giả trong thuộc tính tài liệu + Name of the file this document is saved as Tên tệp của tài liệu + Title from the document properties Tiêu đề trong thuộc tính tài liệu + How many pages the whole document has Tổng số trang của toàn bộ tài liệu + How many pages this section has Tổng số trang của phần này + Number of the page the field sits on Số thứ tự trang nơi trường này nằm + Number of the section the field sits in Số thứ tự phần nơi trường này nằm + How many words the document holds Tổng số từ của toàn bộ tài liệu + Page on which a bookmarked passage falls Trang chứa đoạn được gắn dấu trang + Copy of the text a bookmark marks Bản sao của văn bản được gắn dấu trang + This field has no options. Trường này không có tùy chọn nào. + This font has no characters to show. Phông chữ này không có ký tự nào để hiển thị. + This point forward Từ đây trở đi + The date it is today Ngày hôm nay + Trademark Nhãn hiệu + Type the field's name and its switches, for example PAGE \\* roman. Nhập tên trường và tham số, ví dụ PAGE \\* roman. + Width and spacing Chiều rộng và khoảng cách + from Unicode (hex) từ Unicode (hex) + Shape Effects Hiệu ứng hình + Glow Phát sáng + Soft Edges Cạnh mềm + No Shadow Không bóng đổ + No Glow Không phát sáng + No Soft Edges Không cạnh mềm + Glow Colors Màu phát sáng + Weight Độ dày nét + Shadow, glow and soft edges Bóng đổ, phát sáng và cạnh mềm + Offset: Bottom Right Độ lệch: dưới bên phải + Offset: Bottom Độ lệch: dưới + Offset: Bottom Left Độ lệch: dưới bên trái + Offset: Right Độ lệch: phải + Offset: Center Độ lệch: giữa + Offset: Left Độ lệch: trái + Offset: Top Right Độ lệch: trên bên phải + Offset: Top Độ lệch: trên + Offset: Top Left Độ lệch: trên bên trái + Manage Styles Quản lý kiểu + Show or Hide Style Hiện hoặc ẩn kiểu + Manage Styles… Quản lý kiểu… + Sort order: Thứ tự sắp xếp: + As Recommended Theo đề xuất + Alphabetical Theo thứ tự chữ cái + Font: {font} Phông chữ: {font} + {size} pt {size} pt + Color: {color} Màu: {color} + Space before: {pt} pt Khoảng cách trước: {pt} pt + Space after: {pt} pt Khoảng cách sau: {pt} pt + Based on: {style} Dựa trên: {style} + Paragraph style Kiểu đoạn + Character style Kiểu ký tự + Linked style (paragraph and character) Kiểu liên kết (đoạn và ký tự) + Table style Kiểu bảng + Built-in style Kiểu có sẵn + Show in the Styles gallery Hiện trong thư viện kiểu + Hide from style lists Ẩn khỏi danh sách kiểu + Built-in styles can't be deleted. Không thể xóa các kiểu có sẵn. + Delete Table Style Xóa kiểu bảng + Password Mật khẩu + Password: Mật khẩu: + Confirm password: Xác nhận mật khẩu: + Encrypt with Password… Mã hóa bằng mật khẩu… + Change Password… Đổi mật khẩu… + Remove Password Gỡ mật khẩu + A password is required to open this document. Phải có mật khẩu mới mở được tài liệu này. + Anyone can open this document. Encrypt it with a password to keep it private. Ai cũng mở được tài liệu này. Hãy mã hóa bằng mật khẩu để giữ kín tài liệu. + Anyone who opens the document will need this password. Ai mở tài liệu này cũng cần dùng mật khẩu này. + {name} is protected with a password. {name} đang được bảo vệ bằng mật khẩu. + That password isn't right. Check it (passwords are case-sensitive) and try again. Mật khẩu chưa đúng. Hãy kiểm tra lại (mật khẩu phân biệt chữ hoa, chữ thường) rồi thử lại. + The document will be saved with a password. Save it to protect it. Tài liệu sẽ được lưu kèm mật khẩu. Hãy lưu lại để bảo vệ tài liệu. + The password is removed. Save the document to keep the change. Đã gỡ mật khẩu. Hãy lưu tài liệu để giữ lại thay đổi này. + The passwords don't match. Hai mật khẩu không khớp. + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Hãy giữ mật khẩu ở nơi an toàn: nếu mất thì không ai, kể cả WordCraft, có thể mở được tài liệu. Để trống cả hai ô nếu muốn gỡ mật khẩu. + Align Top Left Căn trên trái + Align Top Center Căn trên giữa + Align Top Right Căn trên phải + Align Center Left Căn giữa trái + Align Center Căn giữa + Align Center Right Căn giữa phải + Align Bottom Left Căn dưới trái + Align Bottom Center Căn dưới giữa + Align Bottom Right Căn dưới phải + {contributors} contributors · {commits} commits {contributors} người đóng góp · {commits} commit + {cols}×{rows} Table Bảng {cols}×{rows} + AaBbYyZz AaBbYyZz + pt pt + Document1 Document1 + Username Tên đăng nhập + Display name Tên hiển thị + Real name Tên thật + Name Tên + PRs PR + Commits Commit + First Đầu + Last Cuối + Model Mô hình + Version Phiên bản + % of all commits % tổng số commit + Lines +/− Dòng +/− + Craft Craft + Studio Studio + Atelier Atelier + Darkroom Darkroom + Gallery Gallery + Sketchbook Sketchbook + Change Picture… Đổi ảnh… + Font… Phông chữ… + Paragraph… Đoạn… + Link… Liên kết… + Size and Crop… Cỡ và cắt xén… + Insert Columns to the Left Chèn cột sang trái + Insert Columns to the Right Chèn cột sang phải + Insert Rows Above Chèn hàng phía trên + Insert Rows Below Chèn hàng phía dưới + Mon T2 + Tue T3 + Wed T4 + Sum Tổng + North Bắc + South Nam + West Tây + Total Tổng cộng + Draw\nTable Vẽ\nbảng + Rotation Xoay + Rotation: Xoay: + Rotate Right 90° Xoay phải 90° + Rotate Left 90° Xoay trái 90° + Flip Vertical Lật dọc + Flip Horizontal Lật ngang + Move Paragraph Up Di chuyển đoạn lên + Move Paragraph Down Di chuyển đoạn xuống + Promote Nâng lên + Demote Hạ xuống + Go Back Quay lại + Toggle Unicode Character Chuyển đổi ký tự Unicode + Toggle Field Codes Bật/tắt mã trường + Edit Field Code Sửa mã trường + Unlink Fields Gỡ liên kết trường + Lock Fields Khóa trường + Unlock Fields Mở khóa trường + Next Field Trường tiếp theo + Previous Field Trường trước đó + Selected Fields Trường đã chọn + Edit Field… Sửa trường… + Formatted Đã định dạng + Interface size: Cỡ giao diện: + In Line with Text Nằm cùng dòng với văn bản + Square Vuông + Through Xuyên qua + Top and Bottom Trên và dưới + Behind Text Sau văn bản + In Front of Text Trước văn bản + Wrap Text Sides Hai bên + Both Sides Cả hai bên + Left Only Chỉ bên trái + Right Only Chỉ bên phải + Largest Only Chỉ lấy loại lớn nhất + Customize Keyboard Tùy chỉnh bàn phím + Choose a command Chọn lệnh + Choose a shortcut Chọn phím tắt + All Commands Mọi lệnh + Commands: Lệnh: + Keys for this command: Phím của lệnh này: + New shortcut: Phím tắt mới: + Now used by {command} Hiện đã gán cho {command} + Not used by any command Chưa gán cho lệnh nào + Remove every custom shortcut and restore the built-in keys? Xóa mọi phím tắt tự đặt và khôi phục phím mặc định? + Assign Gán + Reset All… Đặt lại tất cả… + Press a key combination Nhấn tổ hợp phím + Customize Tùy chỉnh + Keyboard shortcuts: Phím tắt: + Customize… Tùy chỉnh… + Customize Ribbon Tùy chỉnh ribbon + Add to Quick Access Toolbar Thêm vào thanh công cụ truy cập nhanh + Remove from Quick Access Toolbar Gỡ khỏi thanh công cụ truy cập nhanh + Customize the Ribbon… Tùy chỉnh ribbon… + Customize Quick Access Toolbar… Tùy chỉnh thanh công cụ truy cập nhanh… + More Commands… Lệnh khác… + Ribbon Ribbon + (Custom) (Tự tạo) + Remove every custom tab and group and show all tabs again? Xóa mọi tab tự tạo và nhóm tự tạo, hiện lại mọi tab? + Put the default commands back on the Quick Access Toolbar? Đưa lệnh mặc định về lại thanh công cụ truy cập nhanh? + Reset… Đặt lại… + Add >> Thêm >> + << Remove << Gỡ + Pick a custom group (New Group makes one) Chọn nhóm tự tạo (bấm Nhóm mới để tạo) + Ribbon tabs Các tab ribbon + New Tab Tab mới + New Group Nhóm mới + Rename… Đổi tên… + Ribbon and Quick Access Toolbar: Ribbon và thanh công cụ truy cập nhanh: + Accessibility Pane Khung khả năng tiếp cận + Alt Text Pane Khung văn bản thay thế + Alt\nText Văn bản\nthay thế + View Alt Text… Xem văn bản thay thế… + Mark as decorative Đánh dấu là trang trí + Title: Tiêu đề: + Errors ({n}) Lỗi ({n}) + Warnings ({n}) Cảnh báo ({n}) + Tips ({n}) Mẹo ({n}) + No accessibility issues found. Không tìm thấy vấn đề về khả năng tiếp cận nào. + Describe what this object shows, for people who can't see it. Mô tả những gì đối tượng này cho thấy, cho người không nhìn thấy được. + Decorative objects only add visual interest: screen readers skip them. Các đối tượng trang trí chỉ để tăng phần nhìn: trình đọc màn hình sẽ bỏ qua chúng. + Describe what this table shows, for people who can't see it. Mô tả những gì bảng này cho thấy, cho người không nhìn thấy được. + Select a picture, shape, chart or table to describe it. Chọn ảnh, hình, biểu đồ hoặc bảng để mô tả. + Document Grid Lưới tài liệu + No grid Không lưới + Lines only Chỉ dòng + Lines and characters Dòng và ký tự + Text snaps to characters Văn bản căn theo ký tự + Characters per line: Số ký tự mỗi dòng: + Lines per page: Số dòng mỗi trang: + Moved from Đã di chuyển từ + Moved to Đã di chuyển đến + Developer Nhà phát triển + Controls Điều khiển + Rich Text Content Control Điều khiển nội dung văn bản có định dạng + Plain Text Content Control Điều khiển nội dung văn bản thuần + Picture Content Control Điều khiển nội dung ảnh + Building Block Gallery Content Control Điều khiển nội dung thư viện khối + Check Box Content Control Điều khiển nội dung hộp kiểm + Combo Box Content Control Điều khiển nội dung hộp tổ hợp + Drop-Down List Content Control Điều khiển nội dung danh sách xổ xuống + Date Picker Content Control Điều khiển nội dung chọn ngày + Repeating Section Content Control Điều khiển nội dung phần lặp lại + Design Mode Chế độ thiết kế + Content Control at Selection Điều khiển nội dung tại vùng chọn + Toggle Check Box Bật/tắt hộp kiểm + Choose List Item Chọn mục danh sách + Set Date Đặt ngày + Remove Content Control Gỡ điều khiển nội dung + Content Control Properties Thuộc tính điều khiển nội dung + Title Tiêu đề + Tag Thẻ + Placeholder text Chữ giữ chỗ + Can't be deleted Không xóa được + Contents can't be edited Phần nội dung không sửa được + Remove the control once edited Gỡ điều khiển nội dung sau khi sửa + Allow line breaks Cho phép ngắt dòng + Date format Định dạng ngày + Checked symbol Ký hiệu đã chọn + Unchecked symbol Ký hiệu chưa chọn + List entries Các mục danh sách + Display text Văn bản hiển thị + Value Giá trị + Today Hôm nay + Put the caret in a content control first. Đặt con trỏ vào điều khiển nội dung trước. + Right-to-Left Table Bảng từ phải sang trái + Right-to-Left Section Phần từ phải sang trái + Table direction: Hướng bảng: + Section direction: Hướng phần: + Drawing Canvas Khung vẽ + New Drawing Canvas Khung vẽ mới + Fit Vừa khít + Expand Mở rộng + Scale Drawing Tỉ lệ bản vẽ + Canvas Member Thành phần khung vẽ + Arrows Mũi tên + Communication Liên lạc + Office Văn phòng + People Con người + Nature Thiên nhiên + Weather Thời tiết + Technology Công nghệ + Transport Giao thông + Icon Color Màu biểu tượng + Search icons Tìm biểu tượng + No icons match your search. Không có biểu tượng nào khớp với tìm kiếm của bạn. + Recolor a one-color picture, such as an icon Tô màu lại ảnh đơn sắc, ví dụ như biểu tượng + Enclose Characters Bao quanh ký tự + Shrink text Thu nhỏ chữ + Enlarge symbol Phóng to ký hiệu + Enclosure Hình bao quanh + Circle Hình tròn + Change Chart Type Đổi kiểu biểu đồ + Chart Design Thiết kế biểu đồ + Type Loại + Edit Data Sửa số liệu + Chart Title Tiêu đề biểu đồ + Chart Layouts Bố cục biểu đồ + Add Chart Element Thêm thành phần biểu đồ + Legend Chú giải + Data Labels Nhãn số liệu + Above Chart Phía trên biểu đồ + Insert Chart Chèn biểu đồ + Clustered Column Cột nhóm + Stacked Column Cột xếp chồng + Clustered Bar Thanh nhóm + Stacked Bar Thanh xếp chồng + Area Vùng + Pie Biểu đồ tròn + Doughnut Vành khuyên + Scatter Phân tán + “{text}” ({row}, {series}) isn't a number. “{text}” ({row}, {series}) không phải là số. + Category {n} Hạng mục {n} + Series {n} Chuỗi {n} + Type the categories down the side and a column of values per series. Nhập các hạng mục dọc theo cột bên, mỗi chuỗi một cột số liệu. + Categories Hạng mục + Series name Tên chuỗi + Remove series Gỡ chuỗi + Remove row Gỡ hàng + Add Row Thêm hàng + Add Series Thêm chuỗi + Edit\nData Sửa\nsố liệu + Change\nChart Type Đổi\nkiểu biểu đồ + Align Text Căn chữ + Create Link Tạo liên kết + Break Link Gỡ liên kết + Rotate all text 90° Xoay toàn bộ chữ 90° + Rotate all text 270° Xoay toàn bộ chữ 270° + Stacked Xếp chồng + Middle Giữa + Click the empty text box the text should continue in (Esc cancels) Nhấp vào hộp văn bản trống mà văn bản sẽ tiếp tục trong đó (Esc hủy) + Not linked: click a text box Chưa liên kết: nhấp vào một hộp văn bản + Location Vị trí + Endnotes Chú thích cuối tài liệu + Below text Dưới văn bản + End of section Cuối phần + End of document Cuối tài liệu + Footnotes to endnotes Chú thích chân trang thành chú thích cuối tài liệu + Endnotes to footnotes Chú thích cuối tài liệu thành chú thích chân trang + Swap footnotes and endnotes Đổi chỗ chú thích chân trang và chú thích cuối tài liệu + Custom mark: Ký hiệu tùy chỉnh: + Continuous numbering Đánh số liên tục + Apply Áp dụng + Convert Notes Chuyển đổi chú thích chân trang và cuối tài liệu + A, B, C, … A, B, C, … + Above the item Phía trên mục + Accessed on Ngày truy cập + Add New Source… Thêm nguồn mới… + Add the chapter number Thêm số chương + Atmosphere Khí quyển + Below the item Phía dưới mục + Book Sách + Book title Tên sách + Caption Chú thích + Caption Numbering Đánh số chú thích + Caption text Nội dung chú thích + Caption: Chú thích: + Chapter in a book Chương trong sách + Chapters begin at: Các chương bắt đầu từ: + Collect entries from: Mục được thu thập từ: + Columns: Cột: + Conference Hội nghị + Conference paper Bài hội nghị + Container Title Tiêu đề ấn phẩm mẹ + Create Source Tạo nguồn + Cross-reference: Tham chiếu chéo: + Custom Table of Contents… Mục lục tùy chỉnh… + DOI DOI + Day Ngày + Delete Label Gỡ nhãn + Edit Source Sửa nguồn + Edition Ấn bản + Edit… Sửa… + Entry marked. Đã đánh dấu mục. + Example: {sample} Ví dụ: {sample} + Figure Hình + Film Phim + Heading styles (levels 1–{n}) Kiểu tiêu đề (cấp 1–{n}) + Heading {n} Tiêu đề {n} + I, II, III, … I, II, III, … + Include page numbers Bao gồm số trang + Indented Thụt lề + Institution Cơ quan + Ionosphere Tầng điện ly + Issue Số + Journal Tạp chí + Label: Nhãn: + Last, First; Last, First Họ, Tên; Họ, Tên + Leave the label out of the caption Bỏ nhãn khỏi chú thích + Letter headings Tiêu đề chữ cái + Levels: Mức: + Link entries to their headings Nối mục với đề mục + Made from the author and year when empty Được tạo từ tên tác giả và năm khi để trống + Magazine or newspaper article Bài báo/tạp chí + Main entry: Mục chính: + Mark Đánh dấu + Mark All Đánh dấu tất cả + Mark Entry… Đánh dấu mục… + Mark Index Entry Đánh dấu mục chỉ mục + Mesosphere Tầng giữa + Month Tháng + New Label Nhãn mới + New Label… Nhãn mới… + New… Tạo mới… + No sources yet. Chưa có nguồn nào. + Numbering… Đánh số… + Options… Tùy chọn… + Other Khác + Other styles and their TOC level: Các kiểu khác và cấp mục lục: + Outline levels Các cấp dàn ý + Page number style Định dạng số trang + Page numbers at the right margin Số trang ở lề phải + Page title Tiêu đề trang + Pages of bookmark: Trang của dấu trang: + Periodical Định kỳ + Position: Vị trí: + Publisher Nhà xuất bản + Run-in Liền đoạn + See Xem + Separator: Dấu phân cách: + Show every bibliography field Hiện mọi trường tài liệu tham khảo + Site name Tên trang + Source Manager Quản lý nguồn + Source type: Loại nguồn: + Sources in this document: Nguồn trong tài liệu này: + Styles: Kiểu: + Subentry: Mục phụ: + TC entry fields Trường mục TC + Tab leader: Ký tự dẫn tab: + Table of Contents Options Tùy chọn mục lục + Table of Contents Styles Kiểu mục lục + This page Trang này + Type: Loại: + URL URL + Volume Tập + Website Trang web + Year Năm + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. {n} mục được đánh dấu. + Lasso Select Chọn bằng khoanh vùng + Move Selected Ink Di chuyển mực đã chọn + Delete Selected Ink Xóa mực đã chọn + Recolor Selected Ink Đổi màu mực đã chọn + Add Pen Thêm bút + Remove Pen Gỡ bút + Ink to Shape Mực thành hình + Convert to Shapes Chuyển thành hình + Ink Replay Phát lại mực + Add\nPen Thêm\nbút + Rewind Tua lại + Forward Tua tới + Stroke {n} of {total} Nét {n} trên tổng số {total} + WordArt Styles Kiểu WordArt + Text Fill Tô chữ + Text Outline Viền chữ + Transform Biến đổi + No Fill Không tô + No Outline Không viền + Reflection Phản chiếu + No Reflection Không phản chiếu + No Transform Không biến đổi + Connect Shapes Nối hình + Straight Connector Đường nối thẳng + Straight Arrow Connector Đường nối thẳng có mũi tên + Elbow Connector Đường nối gấp khúc + Elbow Arrow Connector Đường nối gấp khúc có mũi tên + Curved Connector Đường nối cong + Curved Arrow Connector Đường nối cong có mũi tên + Arch Up Vòm lên + Arch Down Vòm xuống + Slant Up Nghiêng lên + Slant Down Nghiêng xuống + Inflate Phồng + Deflate Xẹp + Chevron Up Chữ V lên + Chevron Down Chữ V xuống + Triangle Up Tam giác lên + Triangle Down Tam giác xuống + Solid Accent Màu nhấn đặc + Light with Outline Sáng có viền + Hollow Rỗng + Accent Gradient Chuyển sắc màu nhấn + Dark with Shadow Tối có bóng đổ + Soft Glow Phát sáng nhẹ + Accent with Reflection Màu nhấn có phản chiếu + Warm Gradient with Outline Chuyển sắc ấm có viền + Add line numbering Thêm đánh số dòng + Add to Document Thêm vào tài liệu + Address labels Nhãn địa chỉ + Arabic Tiếng Ả Rập + Auto Tự động + Automatically hyphenate document Tự động ngắt từ trong tài liệu + Chinese (Simplified) Tiếng Trung (giản thể) + Chinese (Traditional) Tiếng Trung (phồn thể) + Column: Cột: + Count by: Đếm theo: + Czech Tiếng Séc + Danish Tiếng Đan Mạch + Delivery address: Địa chỉ người nhận: + Detect language automatically Tự động phát hiện ngôn ngữ + Do not check spelling or grammar Không kiểm tra chính tả hoặc ngữ pháp + Dutch Tiếng Hà Lan + English (United Kingdom) Tiếng Anh (Anh) + Envelope size: Cỡ phong bì: + Estonian Tiếng Estonia + Finnish Tiếng Phần Lan + French (France) Tiếng Pháp (Pháp) + From text: Cách văn bản: + Full page of the same label Cả trang cùng một nhãn + German (Germany) Tiếng Đức (Đức) + Greek Tiếng Hy Lạp + Hebrew Tiếng Do Thái + Hyphenate at: Ngắt từ tại: + Hyphenate words in CAPS Ngắt từ viết HOA + Hyphenation Options… Tùy chọn ngắt từ… + Hyphenation zone: Vùng ngắt từ: + Italian Tiếng Ý + Japanese Tiếng Nhật + Korean Tiếng Hàn + Limit consecutive hyphens to: Giới hạn gạch nối liên tiếp: + Line Numbering Options… Tùy chọn đánh số dòng… + Manual Hyphenation Ngắt từ thủ công + Manual hyphenation is complete. Ngắt từ thủ công đã hoàn tất. + Manual… Thủ công… + Mark selected text as: Đánh dấu chữ đã chọn là: + New Document Tạo tài liệu mới + No limit Không giới hạn + Norwegian (Bokmål) Tiếng Na Uy (Bokmål) + Numbering: Đánh số: + Omit Bỏ qua + One label per recipient Một nhãn cho mỗi người nhận + Polish Tiếng Ba Lan + Portuguese (Brazil) Tiếng Bồ Đào Nha (Brazil) + Portuguese (Portugal) Tiếng Bồ Đào Nha (Bồ Đào Nha) + Return address labels Nhãn địa chỉ người gửi + Return address: Địa chỉ người gửi: + Row: Hàng: + Russian Tiếng Nga + Serbian (Cyrillic) Tiếng Serbia (chữ Kirin) + Serbian (Latin) Tiếng Serbia (chữ Latin) + Set As Default Đặt làm mặc định + Shipping labels Nhãn vận chuyển + Single label Nhãn đơn + Spanish (Mexico) Tiếng Tây Ban Nha (Mexico) + Spanish (Spain) Tiếng Tây Ban Nha (Tây Ban Nha) + Swedish Tiếng Thụy Điển + Turkish Tiếng Thổ Nhĩ Kỳ + Ukrainian Tiếng Ukraina + {language} is now the document's default language. {language} hiện là ngôn ngữ mặc định của tài liệu. + 1×2 Empty Matrix Ma trận trống 1×2 + 1×3 Empty Matrix Ma trận trống 1×3 + 2×1 Empty Matrix Ma trận trống 2×1 + 2×2 Determinant Định thức 2×2 + 2×2 Empty Matrix Ma trận trống 2×2 + 2×2 Identity Matrix Ma trận đơn vị 2×2 + 2×2 Identity Matrix (sparse) Ma trận đơn vị 2×2 (thưa) + 2×2 Matrix in Brackets Ma trận 2×2 trong ngoặc vuông + 2×2 Matrix in Double Bars Ma trận 2×2 trong thanh dọc kép + 2×2 Matrix in Parentheses Ma trận 2×2 trong ngoặc đơn + 2×3 Empty Matrix Ma trận trống 2×3 + 3×1 Empty Matrix Ma trận trống 3×1 + 3×2 Empty Matrix Ma trận trống 3×2 + 3×3 Empty Matrix Ma trận trống 3×3 + 3×3 Identity Matrix Ma trận đơn vị 3×3 + 3×3 Identity Matrix (sparse) Ma trận đơn vị 3×3 (thưa) + A xor B A XOR B + Absolute value cases Trường hợp giá trị tuyệt đối + Accents Dấu + Acute Dấu sắc + Angle Brackets Ngoặc góc + Angle Brackets with Separator Ngoặc góc có dấu phân cách + Angle Brackets with Two Separators Ngoặc góc có hai dấu phân cách + Baseline Dots Chấm trên đường cơ sở + Basic Math Toán cơ bản + Bayes' rule Quy tắc Bayes + Binomial Coefficient Hệ số nhị thức + Binomial Coefficient (angle) Hệ số nhị thức (ngoặc góc) + Binomial Coefficient with Angle Brackets Hệ số nhị thức với ngoặc góc + Binomial expansion of (x+a)ⁿ Khai triển nhị thức của (x+a)ⁿ + Boxed Formula Công thức đóng khung + Boxed Formula (example) Ví dụ công thức đóng khung + Boxed Formulas Công thức đóng khung + Braces Dấu ngoặc nhọn + Braces with Separator Dấu ngoặc nhọn có dấu phân cách + Brackets Dấu ngoặc vuông + Brackets with Separators Các dấu ngoặc có dấu phân cách + Breve Dấu trăng + Cases (Three Conditions) Trường hợp ba điều kiện + Cases (Two Conditions) Trường hợp hai điều kiện + Cases and Stacks Trường hợp và xếp chồng + Ceiling Dấu ngoặc trần + Change in y over change in x Biến thiên của y trên biến thiên của x + Check Dấu mũ ngược + Circle area from its radius Diện tích hình tròn theo bán kính + Circled Dot Chấm khoanh tròn + Circled Plus Cộng khoanh tròn + Circled Times Nhân khoanh tròn + Colon Equal Hai chấm bằng + Common Accent Objects Các đối tượng dấu thông dụng + Common Brackets Các ngoặc thông dụng + Common Fractions Các phân số thông dụng + Common Functions Các hàm thông dụng + Common Large Operators Các toán tử lớn thông dụng + Common Operator Structures Các cấu trúc toán tử thông dụng + Common Operators Các toán tử thông dụng + Common Radicals Các căn thức thông dụng + Common Subscripts and Superscripts Các chỉ số dưới và chỉ số trên thông dụng + Contour Integral Tích phân đường + Contour Integral with Limits Tích phân đường có cận + Contour Integral with Stacked Limits Tích phân đường có cận xếp chồng + Contour Integrals Các tích phân đường + Coproduct Đối tích + Coproduct with Limits Đối tích có cận + Coproduct with Lower Limit Đối tích có cận dưới + Coproduct with Subscript-Superscript Limits Đối tích có cận chỉ số dưới-trên + Cosecant Function Hàm côséc + Cosine 2x Côsin 2x + Cosine Function Hàm côsin + Cotangent Function Hàm côtang + Cube Root Căn bậc ba + Delta Equal To Delta bằng + Delta over arrow Delta trên mũi tên + Derivative as a limit Đạo hàm dạng giới hạn + Diagonal Dots Chấm chéo xuống + Differential Vi phân + Differential theta Vi phân theta + Differential x Vi phân x + Differential y Vi phân y + Differentials Các vi phân + Dot Dấu chấm + Dots Các dấu chấm + Double Bar Thanh ngang đôi + Double Dot Dấu chấm đôi + Double Integral Tích phân kép + Double Integral with Limits Tích phân kép có cận + Double Integral with Stacked Limits Tích phân kép có cận xếp chồng + Double Left Arrow with Text Above Mũi tên đôi sang trái có chữ phía trên + Double Left Arrow with Text Below Mũi tên đôi sang trái có chữ phía dưới + Double Left Right Arrow with Text Above Mũi tên đôi hai chiều có chữ phía trên + Double Left Right Arrow with Text Below Mũi tên đôi hai chiều có chữ phía dưới + Double Right Arrow with Text Above Mũi tên đôi sang phải có chữ phía trên + Double Right Arrow with Text Below Mũi tên đôi sang phải có chữ phía dưới + Double Square Brackets Ngoặc vuông kép + Double Vertical Bars Thanh dọc kép + Double summation Tổng kép + Empty Matrices Ma trận trống + Equal Equal Dấu bằng kép + Equal by Definition Dấu bằng theo định nghĩa + Euler's identity Đẳng thức Euler + Exponential Hàm mũ + Exponential series Chuỗi hàm mũ + Floor Dấu ngoặc sàn + Functions Hàm số + Geometry Hình học + Grave Dấu huyền + Greek Letters Chữ cái Hy Lạp + Grouping Character Above Ký tự nhóm phía trên + Grouping Character Below Ký tự nhóm phía dưới + Hat Dấu mũ + Hyperbolic Cosecant Function Hàm côséc hyperbol + Hyperbolic Cosine Function Hàm côsin hyperbol + Hyperbolic Cotangent Function Hàm côtang hyperbol + Hyperbolic Functions Các hàm hyperbol + Hyperbolic Secant Function Hàm sec hyperbol + Hyperbolic Sine Function Hàm sin hyperbol + Hyperbolic Tangent Function Hàm tang hyperbol + Hypotenuse Cạnh huyền + Identity Matrices Ma trận đơn vị + Integral of e^(−x²) Tích phân của e mũ trừ x bình + Integral with Limits Tích phân có cận + Integral with Stacked Limits Tích phân có cận xếp chồng + Integrals Tích phân + Intersection Phép giao + Intersection with Limits Phép giao có cận + Intersection with Lower Limit Phép giao có cận dưới + Intersection with Subscript-Superscript Limits Phép giao có cận chỉ số dưới-trên + Inverse Cosecant Function Hàm côséc ngược + Inverse Cosine Function Hàm côsin ngược + Inverse Cotangent Function Hàm côtang ngược + Inverse Functions Các hàm ngược + Inverse Hyperbolic Cosecant Function Hàm côséc hyperbol ngược + Inverse Hyperbolic Cosine Function Hàm côsin hyperbol ngược + Inverse Hyperbolic Cotangent Function Hàm côtang hyperbol ngược + Inverse Hyperbolic Functions Các hàm hyperbol ngược + Inverse Hyperbolic Secant Function Hàm sec hyperbol ngược + Inverse Hyperbolic Sine Function Hàm sin hyperbol ngược + Inverse Hyperbolic Tangent Function Hàm tang hyperbol ngược + Inverse Secant Function Hàm sec ngược + Inverse Sine Function Hàm sin ngược + Inverse Tangent Function Hàm tang ngược + Large Lớn + Left Arrow with Text Above Mũi tên sang trái có chữ phía trên + Left Arrow with Text Below Mũi tên sang trái có chữ phía dưới + Left Bracket Twice Ngoặc vuông trái kép + Left Right Arrow Above Mũi tên hai chiều phía trên + Left Right Arrow with Text Above Mũi tên hai chiều có chữ phía trên + Left Right Arrow with Text Below Mũi tên hai chiều có chữ phía dưới + Left Subscript-Superscript Chỉ số dưới-trên bên trái + Leftwards Arrow Above Mũi tên hướng trái phía trên + Leftwards Harpoon Above Mũi tên móc hướng trái phía trên + Letter-Like Symbols Ký hiệu dạng chữ cái + Limit Giới hạn + Limit Example Ví dụ giới hạn + Limit and Giới hạn và + Log Logarit + Linear Fraction Phân số tuyến tính + Logarithm Logarit + Logarithm with Base Logarit với cơ số + Logical AND VÀ logic + Logical AND with Limits VÀ logic có cận + Logical OR HOẶC logic + Logical OR with Limits HOẶC logic có cận + Matrices with Brackets Ma trận với dấu ngoặc + Maximum Cực đại + Maximum Example Ví dụ cực đại + Measured By Được đo bằng + Midline Dots Chấm giữa dòng + Minimum Tối thiểu + Minus Equal Trừ bằng + Natural Logarithm Logarit tự nhiên + Negated Relations Quan hệ phủ định + Normal probability density Mật độ xác suất chuẩn + One half Một phần hai + Operator Structures Cấu trúc toán tử + Operators Toán tử + Other Large Operators Các toán tử lớn khác + Outward Brackets Ngoặc vuông hướng ra ngoài + Overbar Gạch trên + Overbars and Underbars Gạch trên và gạch dưới + Overbrace Dấu ngoặc ôm trên + Parentheses Dấu ngoặc đơn + Parentheses with Separator Dấu ngoặc đơn có dấu phân cách + Partial Differential Vi phân riêng + Pi Over 2 Pi trên 2 + Plus Equal Cộng bằng + Power series of (1+x)ⁿ Chuỗi lũy thừa của (1+x)ⁿ + Product Tích + Product of A Tích của A + Product with Limits Tích có cận + Product with Lower Limit Tích có cận dưới + Product with Subscript-Superscript Limits Tích có cận chỉ số dưới-trên + Products and Coproducts Tích và đối tích + Quadratic Root Nghiệm bậc hai + Radical with Degree Căn thức có bậc + Radicals Các căn thức + Right Arrow with Text Above Mũi tên sang phải có chữ phía trên + Right Arrow with Text Below Mũi tên sang phải có chữ phía dưới + Right Bracket Twice Ngoặc vuông phải kép + Rightwards Arrow Above Mũi tên hướng phải phía trên + Rightwards Harpoon Above Mũi tên móc hướng phải phía trên + Roots of ax²+bx+c Các nghiệm của ax²+bx+c + Scripts Chữ cái script + Secant Function Hàm sec + Sides of a right triangle Các cạnh của tam giác vuông + Sine Function Hàm sin + Sine and cosine series of f(x) Chuỗi sin và côsin của f(x) + Sine theta Sin theta + Single Brackets Ngoặc một phía + Single Left Angle Ngoặc góc trái đơn + Single Left Bar Thanh dọc trái đơn + Single Left Brace Ngoặc nhọn trái đơn + Single Left Bracket Ngoặc vuông trái đơn + Single Left Parenthesis Ngoặc đơn trái đơn + Single Right Brace Ngoặc nhọn phải đơn + Single Right Bracket Ngoặc vuông phải đơn + Single Right Parenthesis Ngoặc đơn phải đơn + Skewed Fraction Phân số xiên + Small change Thay đổi nhỏ + Sparse Matrices Ma trận thưa + Square Root Căn bậc hai + Square Root with Degree Căn bậc hai có bậc + Square Union Hợp vuông + Stack Object Đối tượng xếp chồng + Stack Object (Three) Đối tượng xếp chồng ba + Stacked Fraction Phân số xếp chồng + Stacked, no bar Xếp chồng không gạch + Subscript-Superscript Chỉ số dưới-trên + Subscripts and Superscripts Chỉ số dưới và chỉ số trên + Sum of cosines Tổng các côsin + Sum or difference of sines Tổng hoặc hiệu các sin + Summation Phép tính tổng + Summation from 0 to n Tổng từ 0 đến n + Summation over binomials Tổng các hệ số nhị thức + Summation with Limits Tổng có cận + Summation with Lower Limit Tổng có cận dưới + Summation with Subscript Limit Tổng có cận chỉ số dưới + Summation with Subscript-Superscript Limits Tổng có cận chỉ số dưới-trên + Summations Các phép tính tổng + Surface Integral Tích phân mặt + Surface Integral with Limits Tích phân mặt có cận + Surface Integral with Stacked Limits Tích phân mặt có cận xếp chồng + Tangent Function Hàm tang + Tangent formula Công thức tang + Text over Colon Equal Chữ trên hai chấm bằng + Text over Equal Chữ trên dấu bằng + Tilde Dấu ngã + Trigonometric Functions Hàm lượng giác + Triple Dot Dấu chấm ba + Triple Integral Tích phân ba lớp + Triple Integral with Limits Tích phân ba lớp có cận + Triple Integral with Stacked Limits Tích phân ba lớp có cận trên/dưới ký hiệu + Underbar Gạch dưới + Underbrace Dấu ngoặc nhọn phía dưới + Union Phép hợp + Union of intersections Phép hợp của các phép giao + Union with Limits Phép hợp có cận trên/dưới ký hiệu + Union with Lower Limit Phép hợp có cận dưới ký hiệu + Union with Plus Phép hợp có dấu cộng + Union with Subscript-Superscript Limits Phép hợp có cận dạng chỉ số dưới/trên + Unions and Intersections Các phép hợp và giao + Up Diagonal Dots Dấu ba chấm chéo lên + Vector A Vectơ A + Vector AB Vectơ AB + Vertical Bars Dấu gạch dọc + Vertical Dots Dấu ba chấm dọc + Volume Integral Tích phân thể tích + Volume Integral with Limits Tích phân thể tích có cận bên cạnh + Volume Integral with Stacked Limits Tích phân thể tích có cận trên/dưới ký hiệu + Yields Suy ra + n×n Identity Matrix Ma trận đơn vị n×n + n×n Matrix Ma trận n×n + x bar x có gạch trên + APA APA + ASAP SỚM NHẤT CÓ THỂ + AaBbCcDd AaBbCcDd + All - Linear Tất cả - Dạng tuyến tính + All - Professional Tất cả - Dạng trình bày + B4 (JIS) 10.12" × 14.33" Giấy B4 (JIS) 10.12" × 14.33" + B5 (JIS) 7.17" × 10.12" Giấy B5 (JIS) 7.17" × 10.12" + Body text in a short paragraph to show spacing. Đoạn văn ngắn minh họa giãn cách. + Book Title Tên sách + CONFIDENTIAL TÀI LIỆU MẬT + Category Phân loại + Centered as Group Căn giữa cả nhóm + Change to Level 1 Chuyển sang cấp 1 + Change to Level 2 Chuyển sang cấp 2 + Change to Level 3 Chuyển sang cấp 3 + Change to Level 4 Chuyển sang cấp 4 + Change to Level 5 Chuyển sang cấp 5 + Chicago Chicago + Contributors Người đóng góp + Convert between built-up and linear form Chuyển đổi giữa dạng trình bày và dạng tuyến tính + Current - Linear Hiện tại - Dạng tuyến tính + Current - Professional Hiện tại - Dạng trình bày + DO NOT COPY KHÔNG SAO CHÉP + DRAFT BẢN NHÁP + Emphasis Nhấn mạnh + Endnote Reference Dấu chú thích cuối tài liệu + Endnote Text Văn bản chú thích cuối tài liệu + Envelope #10 4.12" × 9.50" Phong bì #10 4.12" × 9.50" + Envelope DL 4.33" × 8.66" Phong bì DL 4.33" × 8.66" + Executive 7.25" × 10.50" Giấy Executive 7.25" × 10.50" + Footnote Reference Dấu chú thích chân trang + Footnote Text Văn bản chú thích chân trang + GB/T 7714 GB/T 7714 + GOST (name sort) GOST (xếp theo tên tác giả) + GOST (title sort) GOST (xếp theo tiêu đề) + Good evening Chào buổi tối + Grid Table 1 Light Bảng lưới 1 nhạt + Grid Table 4 – Blue Bảng lưới 4 – Xanh dương + Grid Table 4 – Green Bảng lưới 4 – Xanh lá + Grid Table 4 – Orange Bảng lưới 4 – Cam + Grid Table 4 – Purple Bảng lưới 4 – Tím + Harvard (Anglia) Harvard (Anglia) + Hyperlink Siêu liên kết + IEEE IEEE + ISO 690 (author-date) ISO 690 (tác giả-năm) + ISO 690 (numerical) ISO 690 (đánh số) + Intense Emphasis Nhấn mạnh nổi bật + Intense Quote Trích dẫn nổi bật + Intense Reference Tham chiếu nổi bật + Justification of a display equation Căn chỉnh phương trình trên dòng riêng + Keywords Từ khóa + Legal 8.50" × 14.00" Giấy Legal 8.50" × 14.00" + Letter 8.50" × 11.00" Giấy Letter 8.50" × 11.00" + Level 1 Cấp 1 + Level 2 Cấp 2 + Level 3 Cấp 3 + List Paragraph Đoạn danh sách + List Table 3 – Blue Bảng liệt kê 3 – Xanh dương + List Table 3 – Green Bảng liệt kê 3 – Xanh lá + List Table 3 – Orange Bảng liệt kê 3 – Cam + List Table 3 – Purple Bảng liệt kê 3 – Tím + MLA MLA + Models Mô hình + No Spacing Không giãn cách + Normal Thường + Number the equation; the number sits at the right margin (or type # then the number) Đánh số phương trình; số nằm ở lề phải (hoặc gõ # rồi gõ số) + Plain Table 1 Bảng đơn giản 1 + Quote Trích dẫn + SAMPLE BẢN MẪU + SIST02 SIST02 + Show the equation in the line of text Hiện phương trình chung dòng với văn bản + Show the equation on a line of its own Hiện phương trình trên dòng riêng + Statement 5.50" × 8.50" Giấy Statement 5.50" × 8.50" + Strong Đậm + Subject Chủ đề + Subtitle Tiêu đề phụ + Subtle Emphasis Nhấn mạnh nhẹ + Subtle Reference Tham chiếu nhẹ + TOC Heading Tiêu đề mục lục + Table Grid Lưới bảng + Tabloid 11.00" × 17.00" Giấy Tabloid 11.00" × 17.00" + Turabian Turabian + Type equations in LaTeX Gõ phương trình bằng LaTeX + Type equations in the linear format (UnicodeMath) Gõ phương trình theo dạng tuyến tính (UnicodeMath) + Type normal (non-math) text in the equation Gõ văn bản thường (không phải toán) trong phương trình + URGENT KHẨN + tOGGLE cASE đẢO hOA tHƯỜNG + ✓ None ✓ Không có + As you type Khi gõ + Asian text font: Phông chữ châu Á: + AutoSave writes your changes to the file a moment after you stop typing. Tự động lưu ghi thay đổi vào tệp một lát sau khi bạn ngừng gõ. + Centimeters Xentimét + Check spelling as you type Kiểm tra chính tả khi gõ + Click to switch back to inserting Nhấp để chuyển lại chế độ chèn + Custom dictionary Từ điển tùy chỉnh + Formatting marks Dấu định dạng + Hidden text Chữ ẩn + Ignore web and file addresses Bỏ qua địa chỉ web và tệp + Ignore words in capitals Bỏ qua từ viết toàn chữ hoa + Ignore words with numbers Bỏ qua từ có số + Inches Inch + Formatting exceptions Ngoại lệ định dạng + Initials: Chữ viết tắt: + Interface Giao diện + Interface language Ngôn ngữ giao diện + Inside: Bên trong: + Latin text font: Phông chữ Latin: + Mark grammar errors as you type Đánh dấu lỗi ngữ pháp khi gõ + Measurement units: Đơn vị đo: + Millimeters Milimét + New comments and tracked changes are recorded under this name and these initials. Nhận xét mới và các thay đổi được theo dõi được ghi dưới tên và chữ viết tắt này. + New word Từ mới + No words yet. Add to Dictionary on a misspelled word puts it here. Chưa có từ nào. “Thêm vào từ điển” trên từ sai chính tả sẽ đưa từ đó vào đây. + Mirror indents Thụt lề đối xứng + Overtype Gõ đè + Overtype (typing replaces the characters after the caret) Gõ đè (gõ sẽ thay các ký tự sau con trỏ) + Page display Hiển thị trang + Paragraph marks Dấu đoạn + Personalize Cá nhân hóa + Picas Pica + Points Điểm + Print the page color In màu nền trang + Printing In ấn + Proofing language Ngôn ngữ hiệu đính + Recent documents to show: Số tài liệu gần đây sẽ hiển thị: + Outside: Bên ngoài: + Save documents Lưu tài liệu + Show the mini toolbar when text is selected Hiện thanh công cụ mini khi chọn chữ + Spaces Khoảng trắng + Spelling Chính tả + Spelling and grammar are checked in English (United States). Soát chính tả và ngữ pháp bằng tiếng Anh (Mỹ). + Tab characters Ký tự tab + These marks show even when Show/Hide ¶ is off. Các dấu này vẫn hiện cả khi đã tắt Hiện/Ẩn ¶. + Typing replaces selected text Gõ sẽ thay thế văn bản đã chọn + Update fields before printing Cập nhật trường trước khi in + Use the Insert key to switch overtype on and off Dùng phím Insert để bật/tắt gõ đè + Extend Selection by Units Mở rộng vùng chọn theo đơn vị + Word XML Document (*.xml) Tài liệu Word XML (*.xml) + Deleted Đã xóa + Print Markup In kèm nhận xét và thay đổi được theo dõi + Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Trong PDF, bản in và ảnh trang: nhận xét bên cạnh mỗi trang và các thay đổi được theo dõi; danh sách nhận xét ở cuối trang web, Markdown và văn bản. + 1. Formatting restrictions 1. Hạn chế định dạng + 2. Editing restrictions 2. Hạn chế chỉnh sửa + 3. Start enforcement 3. Thực thi bảo vệ + << Less << Thu gọn + Another file Tệp khác + Apply as you type Áp dụng khi nhập + AutoCorrect Tự động sửa + AutoFormat As You Type Tự động định dạng khi nhập + Automatic bulleted lists Tự động tạo danh sách có dấu đầu dòng + Automatic numbered lists Tự động tạo danh sách đánh số + Border lines Tự động tạo đường viền + Capitalize first letter of sentences Viết hoa chữ cái đầu câu + Capitalize first letter of table cells Viết hoa chữ cái đầu của mỗi ô bảng + Capitalize names of days Viết hoa tên ngày trong tuần + Case changes Thay đổi chữ hoa/thường + Character level Theo ký tự + Checked styles are allowed: Các kiểu được đánh dấu được phép: + Combine Documents Kết hợp tài liệu + Compare Documents So sánh tài liệu + Compared: {changes} changes Đã so sánh: {changes} thay đổi + Comparing with another file needs the desktop app. So sánh với tệp khác cần dùng ứng dụng trên máy tính. + Comparison settings Tùy chọn so sánh + Don't capitalize after: Không viết hoa sau: + Editing in this document is restricted. Việc chỉnh sửa tài liệu này bị hạn chế. + Every change is recorded as a tracked change. Mọi thay đổi đều được ghi lại dưới dạng thay đổi được theo dõi. + Everyone Mọi người + Exceptions (optional) Ngoại lệ (không bắt buộc) + Exceptions… Ngoại lệ… + Fields Trường + Filling in forms Điền biểu mẫu + Find Next Editable Region Tìm vùng có thể chỉnh sửa tiếp theo + Footnotes and endnotes Chú thích chân trang và chú thích cuối + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Định dạng, thay đổi chữ hoa/thường, khoảng trắng và bảng được đưa vào so sánh; các tùy chọn khác được lưu để dùng sau. + Fractions (1/2) as fraction characters (½) Đổi phân số (1/2) thành ký tự phân số (½) + Headers and footers Đầu trang và chân trang + Hyphens (--) as dashes (—) Đổi dấu gạch nối (--) thành dấu gạch dài (—) + In equations, typing a name after a backslash replaces it with its symbol. Trong phương trình, khi nhập tên sau dấu gạch chéo ngược, tên sẽ được thay bằng ký hiệu tương ứng. + Internet addresses as links Đổi địa chỉ Internet thành liên kết + Label changes with: Gán thay đổi cho: + Limit formatting to a selection of styles Giới hạn định dạng ở các kiểu được chọn + Math AutoCorrect Tự động sửa ký hiệu toán + More >> Mở rộng >> + Moves Nội dung đã di chuyể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: + Only comments can be added, except in regions marked editable. Chỉ có thể thêm nhận xét, trừ các vùng được đánh dấu là có thể chỉnh sửa. + Only form fields can be filled in. Chỉ có thể điền vào các trường biểu mẫu. + Only the allowed styles can be applied. Chỉ có thể áp dụng các kiểu được cho phép. + Ordinals (1st) as superscript Đổi số thứ tự (1st) thành chỉ số trên + Original document Tài liệu gốc + This password type isn't supported. Loại mật khẩu này không được hỗ trợ. + Replace as you type Thay thế khi nhập + Replace text as you type Thay thế văn bản khi nhập + Replace: Thay thế: + Revised document Tài liệu đã chỉnh sửa + Select text, then tick who can still edit it. Chọn văn bản, rồi đánh dấu những người vẫn có thể chỉnh sửa văn bản đó. + Settings… Cài đặt… + Show changes at: Hiển thị thay đổi ở mức: + Show changes in: Hiển thị thay đổi trong: + Smart quotes instead of straight quotes Dùng dấu nháy cong thay dấu nháy thẳng + Start Enforcing Protection Bắt đầu áp dụng bảo vệ + Stop Protection Ngừng bảo vệ + Text boxes Hộp văn bản + The document is read only, except for regions marked editable. Tài liệu ở chế độ chỉ đọc, trừ các vùng được đánh dấu là có thể chỉnh sửa. + The password is optional; without it anyone can stop the protection. Mật khẩu là tùy chọn; nếu không có mật khẩu, bất kỳ ai cũng có thể ngừng bảo vệ. + This document Tài liệu này + Tracked changes Các thay đổi được theo dõi + Type the password to stop the protection. Nhập mật khẩu để ngừng bảo vệ. + White space Khoảng trắng + With: Bằng: + Word level Theo từ + Add Font… Thêm phông chữ… + Added Fonts Phông chữ đã thêm + Remove Added Font Xóa phông chữ đã thêm + Recent Colors Màu gần đây + More Colors… Màu khác… + Standard Chuẩn + Current Hiện tại + Colors: Màu sắc: + Color model: Mô hình màu: + Red: Đỏ: + Green: Xanh lục: + Blue: Xanh lam: + Hue: Sắc độ: + Sat: Độ bão hòa: + Lum: Độ sáng: + Hex: Mã hex: + Templates Mẫu + Templates and Add-ins Mẫu và phần bổ trợ + Attach Template Đính kèm mẫu + Detach Template Gỡ mẫu đã đính kèm + Automatically Update Document Styles Tự động cập nhật kiểu tài liệu + Update Styles from Template Cập nhật kiểu từ mẫu + Organizer Trình quản lý kiểu + Copy Styles Sao chép kiểu + Rename Style Đổi tên kiểu + Document template: Mẫu tài liệu: + Attach… Đính kèm… + Automatically update document styles Tự động cập nhật kiểu tài liệu + Organizer… Trình quản lý kiểu… + Hide White Space Ẩn khoảng giữa trang + All pages Tất cả các trang + Current page Trang hiện tại + Selected pages Các trang đã chọn + Custom range Phạm vi tùy chỉnh + All pages in the range Tất cả trang trong phạm vi + Odd pages only Chỉ trang lẻ + Even pages only Chỉ trang chẵn + Copies Số bản in + Collate copies Xếp các bản in theo bộ + Pages like 1-3, 5 Các trang như 1-3, 5 + {count} of {total} pages will print Sẽ in {count} trong số {total} trang + Save these pages as PDF… Lưu các trang này thành PDF… + The printer, two-sided printing and pages per sheet are chosen in that dialog. Máy in, in hai mặt và số trang trên mỗi tờ được chọn trong hộp thoại đó. + Previous Page Trang trước + Print to PDF In thành PDF + Add Shape Thêm hình + Change Colors Đổi màu + Change Layout Đổi bố cục + Create Graphic Tạo đồ họa + Edit Items Sửa các mục + Layouts Bố cục + Reset Graphic Đặt lại đồ họa + SmartArt Design Thiết kế SmartArt + SmartArt Styles Kiểu SmartArt + Text Pane Ngăn văn bản + List Danh sách + Process Quy trình + Cycle Chu trình + Hierarchy Phân cấp + Relationship Mối quan hệ + Pyramid Kim tự tháp + Basic List Danh sách cơ bản + Basic Process Quy trình cơ bản + Basic Cycle Chu trình cơ bản + Basic Pyramid Kim tự tháp cơ bản + Basic Radial Sơ đồ tỏa tâm cơ bản + Basic Matrix Ma trận cơ bản + Basic Venn Sơ đồ Venn cơ bản + Shows items of equal weight as blocks in rows. Hiển thị các mục có vai trò ngang nhau thành các khối theo hàng. + Shows steps in order, left to right, with arrows between them. Hiển thị các bước theo thứ tự từ trái sang phải, với mũi tên ở giữa các bước. + Shows steps that repeat, around a circle. Hiển thị các bước lặp lại, quanh một vòng tròn. + Shows a tree: each item below the item it belongs to. Hiển thị dạng cây: mỗi mục nằm dưới mục mà nó thuộc về. + Shows parts that build on each other, largest at the bottom. Hiển thị các phần xếp chồng lên nhau, phần lớn nhất ở dưới cùng. + Shows items around a central idea: the first item is the centre. Hiển thị các mục quanh một ý tưởng trung tâm: mục đầu tiên nằm ở giữa. + Shows parts of a whole as quadrants. Hiển thị các phần của một tổng thể theo bốn góc phần tư. + Shows overlapping ideas as overlapping circles. Hiển thị các ý tưởng giao nhau bằng những vòng tròn giao nhau. + Accent {n} Màu nhấn {n} + Colorful Nhiều màu + Insert SmartArt Chèn SmartArt + One line per item. Enter adds an item. Mỗi mục một dòng. Nhấn Enter để thêm mục. + Add\nShape Thêm\nhình + Change\nColors Đổi\nmàu + Reset\nGraphic Đặt lại\nđồ họa + Promote to Heading 1 Nâng lên thành Tiêu đề 1 + Demote to Body Text Hạ xuống thành văn bản nội dung + Move Up Di chuyển lên + Move Down Di chuyển xuống + Collapse Thu gọn + Expand or Collapse Heading Mở rộng hoặc thu gọn tiêu đề + Select Heading and Content Chọn tiêu đề và nội dung + Show Level Hiện cấp + Show Level 1 Hiện cấp 1 + Show Level 2 Hiện dàn ý cấp 2 + Show Level 3 Hiện dàn ý cấp 3 + Show Level 4 Hiện dàn ý cấp 4 + Show Level 5 Hiện dàn ý cấp 5 + Show Level 6 Hiện dàn ý cấp 6 + Show Level 7 Hiện dàn ý cấp 7 + Show Level 8 Hiện dàn ý cấp 8 + Show Level 9 Hiện dàn ý cấp 9 + Show All Levels Hiện mọi cấp + All Levels Mọi cấp + Show First Line Only Chỉ hiện dòng đầu + Show Text Formatting Hiện định dạng văn bản + Close Outline View Đóng chế độ xem dàn ý + Outline State Trạng thái dàn ý + Outlining Lập dàn ý + Outline Tools Công cụ dàn ý + Close Outline\nView Đóng chế độ xem\ndàn ý + Use wildcards Dùng ký tự đại diện + Special Đặc biệt + Match prefix Khớp tiền tố + Match suffix Khớp hậu tố + Ignore punctuation characters Bỏ qua dấu câu + Ignore white-space characters Bỏ qua ký tự khoảng trắng + Regular expressions Biểu thức chính quy + Search Options Tùy chọn tìm kiếm + Tab Character Ký tự tab + Any Character Ký tự bất kỳ + Any Digit Chữ số bất kỳ + Any Letter Chữ cái bất kỳ + Caret Character Ký tự dấu mũ + Section Character Ký tự phần + Paragraph Character Ký tự đoạn văn + Graphic Đồ họa + Manual Line Break Ngắt dòng thủ công + Manual Page Break Ngắt trang thủ công + Section Break Ngắt phần + White Space Khoảng trắng + Clipboard Contents Nội dung bảng nhớ tạm + Find What Text Văn bản cần tìm + Find What Expression Biểu thức cần tìm + Character in Range Ký tự trong khoảng + Beginning of Word Đầu từ + End of Word Cuối từ + Expression Biểu thức + Not Không + Num Occurrences Số lần xuất hiện + Previous 1 or More Lặp mục trước một hoặc nhiều lần + 0 or More Characters Không hoặc nhiều ký tự + Move with Text Di chuyển cùng văn bản + Fix Position on Page Cố định vị trí trên trang + Good morning Chào buổi sáng + Animation Hiệu ứng động + Animate the caret (it glides and fades) Tạo hiệu ứng cho con trỏ văn bản (lướt và mờ dần) + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Bật: con trỏ văn bản lướt đến vị trí mới khi bạn gõ hoặc dùng phím mũi tên, và nhịp nhấp nháy mờ dần. Tắt: con trỏ nhảy đến vị trí mới và nhấp nháy bật tắt. + Documents Tài liệu + Word document Tài liệu Word + All files Tất cả tệp + Recipient lists Danh sách người nhận + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Hộp thoại tệp của hệ thống không khả dụng, nên WordCraft hiển thị hộp thoại riêng. Để dùng hộp thoại của hệ thống, hãy cài đặt cổng chọn tệp (như xdg-desktop-portal-gtk) hoặc zenity. + Up one level Lên một cấp + Home folder Thư mục chính + {name} wasn't found. Không tìm thấy {name}. + {name} already exists. Choose Save again to replace it. {name} đã tồn tại. Chọn Lưu lần nữa để thay thế. + This folder can't be read: {error} Không thể đọc thư mục này: {error} + Showing the first {count} items. Đang hiển thị {count} mục đầu tiên. + File name: Tên tệp: + Browse in WordCraft… Duyệt trong WordCraft… + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Mở tệp bằng trình duyệt tệp của WordCraft khi hộp thoại tệp của hệ thống không xuất hiện. + Resize Table by Dragging Đổi cỡ bảng bằng cách kéo + Visual effects Hiệu ứng hình ảnh + Animation effects: Hiệu ứng động: + On Bật + Off Tắt + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Cuộn và thu phóng mượt mà; menu và nút hiện dần. Khi tắt, mọi thay đổi hiện ra ngay lập tức. + Your system has animation effects turned on. Hệ thống của bạn đã bật hiệu ứng động. + Your system has animation effects turned off. Hệ thống của bạn đã tắt hiệu ứng động. + Your system doesn't report an animation setting, so animation effects stay on. Hệ thống không cung cấp thiết lập hiệu ứng động, nên các hiệu ứng động vẫn bật. + Annotation Reference Tham chiếu nhận xét + Annotation Subject Chủ đề nhận xét + Annotation Text Nội dung nhận xét + Default Paragraph Font Phông chữ mặc định của đoạn + Envelope Address Địa chỉ người nhận trên phong bì + Envelope Return Địa chỉ người gửi trên phong bì + Index Heading Tiêu đề chỉ mục + Index {n} Chỉ mục {n} + Line Number Số dòng + Normal Indent Thụt lề thường + Normal Table Bảng thường + Table of Figures Danh mục hình + TOA Heading Tiêu đề bảng thẩm quyền + TOC {n} Mục lục {n} + Good afternoon Chào buổi chiều + Context Theo ngữ cảnh + Couldn't make WordCraft the default: {error} Không thể đặt WordCraft làm ứng dụng mặc định: {error} + Do Not Check Spelling or Grammar Không kiểm tra chính tả hoặc ngữ pháp + Hindi Tiếng Hindi + In Default apps, choose WordCraft for the document types you want it to open. Trong Ứng dụng mặc định, chọn WordCraft cho các loại tài liệu bạn muốn mở bằng ứng dụng này. + Make WordCraft the default for Word documents… Đặt WordCraft làm ứng dụng mặc định cho tài liệu Word… + Numerals Chữ số + Numerals: Chữ số: + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Từ nay, mở tệp .docx, .doc, .rtf, .odt và mẫu Word bằng WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Mở Cài đặt › Ứng dụng mặc định để chọn WordCraft cho .docx và các loại tài liệu khác. + Reading Đọc + Reading Column Width Độ rộng cột khi đọc + Reading Line Spacing Giãn dòng khi đọc + Reading Text Size Cỡ chữ khi đọc + Show KeyTips (Alt or F10) Hiện gợi ý phím (Alt hoặc F10) + System Hệ thống + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Để mở tài liệu Word bằng WordCraft theo mặc định: chọn một tài liệu trong Finder, chọn Tệp › Lấy thông tin, chọn WordCraft ở mục Mở bằng, rồi nhấp Thay đổi tất cả. + WordCraft is now the default app for Word documents. WordCraft hiện là ứng dụng mặc định cho tài liệu Word. + Correct TWo INitial CApitals Sửa lỗi viết hoa hai chữ cái đầu + Correct accidental usage of cAPS LOCK key Sửa lỗi vô tình dùng phím Caps Lock + Picture Ảnh + Pastes the clipboard's picture. Dán ảnh từ bảng nhớ tạm. + Region: Khu vực: + Automatic ({region}) Tự động ({region}) + Undo 1 Action Hoàn tác 1 thao tác + Undo Actions Hoàn tác các thao tác + Undo History Lịch sử hoàn tác + Undo {n} Actions Hoàn tác {n} thao tác + Start-up Khởi động + Show the Start screen when WordCraft starts Hiện màn hình Bắt đầu khi khởi chạy WordCraft + On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Bật: WordCraft mở ở Tệp › Trang chủ, với tài liệu trống, các mẫu và tài liệu gần đây của bạn. Tắt: WordCraft mở một tài liệu trống. + Typing Nhập liệu + Level 4 Cấp 4 + Level 5 Cấp 5 + Level 6 Cấp 6 + Level 7 Cấp 7 + Level 8 Cấp 8 + Level 9 Cấp 9 + Select Column Chọn cột + Select Table Cells by Clicking Chọn ô trong bảng bằng cách bấm + Insert Row or Column by Clicking Chèn hàng hoặc cột bằng cách bấm + Move Table by Dragging Di chuyển bảng bằng cách kéo + AutoFit Column by Double-Clicking Tự động điều chỉnh cột bằng cách bấm đúp + Insert Caption… Chèn chú thích… + Table Properties… Thuộc tính bảng… + Search Tìm kiếm + Title Bar Thanh tiêu đề + Capitalize the first word of a sentence Viết hoa chữ đầu câu + Change to Level 6 Chuyển sang Mức 6 + Change to Level 7 Chuyển sang Mức 7 + Change to Level 8 Chuyển sang Mức 8 + Change to Level 9 Chuyển sang Mức 9 + Extra space Thừa khoảng trắng + Possible spelling mistake Có thể sai chính tả + Remove the space before the punctuation Bỏ khoảng trắng trước dấu câu + The spelling and grammar check is complete. Đã soát xong chính tả và ngữ pháp. + Use "a" before a consonant sound Dùng "a" trước phụ âm + Use "an" before a vowel sound Dùng "an" trước nguyên âm + ➕ New comment ➕ Nhận xét mới + 📂 Browse… 📂 Duyệt… + Style: APA Kiểu: APA + Auto ({percent}%) Tự động ({percent}%) + Leave out the date and time of comments and tracked changes when saving Bỏ ngày giờ của nhận xét và thay đổi được theo dõi khi lưu + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Làm ribbon, menu và hộp thoại lớn hơn hoặc nhỏ hơn. Tự động theo tỉ lệ hiển thị của hệ thống; 100% là một điểm ảnh màn hình cho mỗi point. Thu phóng tài liệu là mục riêng. + No document is open. Create a document or open one first. Chưa mở tài liệu nào. Hãy tạo hoặc mở một tài liệu trước. + Privacy Options Tùy chọn quyền riêng tư + Privacy Settings Cài đặt quyền riêng tư + Privacy for this document Quyền riêng tư cho tài liệu này + Remove my name from comments, changes and file properties when saving Gỡ tên tôi khỏi nhận xét, thay đổi và thuộc tính tệp khi lưu + The document you are editing keeps the names and dates; only the saved file leaves them out. Tài liệu bạn đang sửa vẫn giữ tên và ngày giờ; chỉ tệp được lưu là bỏ chúng đi. + Trust Center Trung tâm tin cậy + Word 2003 XML Document (*.xml) Tài liệu XML Word 2003 (*.xml) + +# Strings added on main after this catalog was drafted (merge of main) + Column width Độ rộng cột + Croatian Tiếng Croatia + Edit Document Sửa tài liệu + Envelope #10 Phong bì số 10 + Envelope DL Phong bì DL + Full\nScreen Toàn\nmàn hình + Full Screen Toàn màn hình + Go to what: Đi tới: + Language Preferences… Tùy chọn ngôn ngữ… + Reading Mode Chế độ đọc + Researcher needs an online service; WordCraft keeps your documents offline. Trình nghiên cứu cần một dịch vụ trực tuyến; WordCraft giữ tài liệu của bạn ngoại tuyến. + Set Proofing Language… Đặt ngôn ngữ soát lỗi… + Suggested Đề xuất + Text size Cỡ chữ + These settings will be used for new documents. Các thiết đặt này sẽ được dùng cho tài liệu mới. + Type +2 or -1 to move from the page you are on. Nhập +2 hoặc -1 để di chuyển từ trang hiện tại. + Type Character Code Nhập mã ký tự + Use the browser's full screen (F11) Dùng chế độ toàn màn hình của trình duyệt (F11) + can't split at the first row không thể tách ở hàng đầu tiên + select two or more cells to merge hãy chọn hai ô trở lên để phối + there is no ink to replay không có nét mực nào để phát lại + there's no citation after the caret không có trích dẫn nào sau dấu nháy + this item is already at the top level mục này đã ở cấp cao nhất + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + {name} {height} × {width} in {name} {height} × {width} in + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d3c0a7944..9ac38c90e 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -19,8 +19,8 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **19 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr` and Bulgarian `bg`. Catalogs have 2,057–2,454 entries +- **20 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr`, Bulgarian `bg` and Vietnamese `vi` (#1434). Catalogs have 2,057–2,454 entries each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian and Bulgarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. @@ -57,10 +57,10 @@ shared items below. | Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; no vertical text, ruby or document grid | no | partial | 3–5 | | German | de | 2,021 entries; same key set as zh-hans (#574); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 1–2 | | Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 | -| Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 | +| Vietnamese | vi | 2,448 entries; every key of the reference catalogs (#1434); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 0–1 (review) | -Other shipped languages: **13** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian -Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594), Polish (`pl`), Czech (`cs`), Hungarian (`hu`), French (`fr`) and Bulgarian (`bg`), all partial. +Other shipped languages: **14** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian +Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594), Polish (`pl`), Czech (`cs`), Hungarian (`hu`), French (`fr`), Bulgarian (`bg`) and Vietnamese (`vi`, #1434), all partial. Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog. Estonian spelling, grammar, dates and document templates are not added by the interface catalog. From 17ec132c7c6cfe27cd127f4079187fa6f9291c14 Mon Sep 17 00:00:00 2001 From: dfgodvan Date: Wed, 14 Oct 2026 20:19:51 +0300 Subject: [PATCH 053/199] Tables: thick cell borders meet in closed corners (#1461) A horizontal cell edge ran from cell edge to cell edge, so at a thick vertical edge it started on that edge's centre line and left a notch half the border width wide at every corner. Horizontal edges now run on over half of each visible vertical edge, as Word draws them. Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/layout/src/table.rs | 8 ++++++-- crates/layout/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 7ab6b3d76..2a3df74c8 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -707,13 +707,17 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava } let b = c.borders; let (x0, x1, y0, y1) = (c.x, c.x + c.w, 0.0, rh); + // Horizontal edges run on over half of each visible vertical edge, so the corners + // close the way Word draws them instead of leaving a notch at thick borders. + let half = |o: Option| o.filter(Border::is_visible).map_or(0.0, |e| e.width / 2.0); + let (hx0, hx1) = (x0 - half(b.left), x1 + half(b.right)); let top_vis = c.vmerge != VMerge::Continue; if top_vis && let Some(e) = b.top.filter(Border::is_visible) { - items.push(Placed::Rule { x0, y0, x1, y1: y0, border: e }); + items.push(Placed::Rule { x0: hx0, y0, x1: hx1, y1: y0, border: e }); } let last_of_merge = c.vmerge == VMerge::None || !cont.get(ri + 1).is_some_and(|v| v.contains(&c.g)); if last_of_merge && let Some(e) = b.bottom.filter(Border::is_visible) { - items.push(Placed::Rule { x0, y0: y1, x1, y1, border: e }); + items.push(Placed::Rule { x0: hx0, y0: y1, x1: hx1, y1, border: e }); } if let Some(e) = b.left.filter(Border::is_visible) { items.push(Placed::Rule { x0, y0, x1: x0, y1, border: e }); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 95ba1550b..e767a930d 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -764,6 +764,33 @@ fn table_style_borders_survive_empty_tbl_borders() { assert!(rules_on_page0(&l).len() >= 8, "rules {:?}", rules_on_page0(&l)); } +/// Thick borders meet in closed corners: a horizontal edge starts at the outer side of the +/// vertical edge it meets, not at its centre line. +#[test] +fn table_border_corners_close() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(2, 2, 468.0); + let thick = Some(Border::single(3.0)); + for r in &mut t.rows { + for c in &mut r.cells { + c.props.borders = Some(Borders { top: thick, left: thick, bottom: thick, right: thick, ..Borders::default() }); + } + } + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let (mut h_left, mut v_left) = (f32::MAX, f32::MAX); + for it in &l.pages[0].items { + if let Placed::Rule { x0, y0, x1, y1, .. } = it { + if y0 == y1 { + h_left = h_left.min(x0.min(*x1)); + } else if x0 == x1 { + v_left = v_left.min(*x0); + } + } + } + assert!((h_left - (v_left - 1.5)).abs() < 0.01, "horizontal starts at {h_left}, vertical centre line at {v_left}"); +} + #[test] fn table_own_border_overrides_only_that_side() { let mut d = Document::from_text("before\nafter"); From f2a96a21f9f0be2469a856a558d95f90feb4d518 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:20:11 +0200 Subject: [PATCH 054/199] Close prompt: a failed Save says why and keeps the document open (#1445) (#1465) * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/ui-egui/src/dialogs.rs | 12 +- crates/ui-egui/src/file_dialogs.rs | 28 ++++- crates/ui-egui/src/lib.rs | 171 ++++++++++++++++++++++++++--- 4 files changed, 195 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9749c1fbf..133a3ac31 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: table row margins and splitting; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: a failed save from the close prompt says so, #1445; 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. @@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page | | 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole | | 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. | +| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -311,6 +312,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) | | 2026-10-13 | trivial | Progress log: space after a page break | | 2026-10-13 | trivial | Progress log: table row margins and splitting | +| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 975ff9012..4537d5ec4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -355,7 +355,8 @@ pub enum Dialog { /// "Do you want to save changes?" before a user's New, Open, Close, Envelopes, Labels or /// Finish & Merge replaces the document (see [`WordApp::run`]); `then` runs once it is /// answered (`ui.saveChanges`), and only while the document it asked about - /// (`Session::document_id`) is still the one open. + /// (`Session::document_id`) is still the one open. `message`: why the last Save failed, shown + /// in the prompt when it comes back after that failure (#1445); empty otherwise. SaveChanges { name: String, then: String, @@ -363,6 +364,8 @@ pub enum Dialog { params: Value, #[serde(skip)] document: u64, + #[serde(default)] + message: String, }, /// Mailings › Select Recipients / Edit Recipient List: a table of recipients to type or /// edit (field names on top), or a file to load instead (#240). @@ -2345,9 +2348,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, tl!("Close")); ok || cancel } - Dialog::SaveChanges { name, .. } => { + Dialog::SaveChanges { name, message, .. } => { ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Do you want to save changes to {name}?"), &[("name", name)])).font(semibold(15.0))); ui.label(tl!("Your changes will be lost if you don't save them.")); + // Why the last Save failed (#1445): the prompt came back so the user can't miss it. + if !message.is_empty() { + ui.add_space(4.0); + ui.label(egui::RichText::new(message.as_str()).color(ui.visuals().error_fg_color)); + } ui.add_space(8.0); let buttons = ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index a8ff95ecf..ff770eac8 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -238,10 +238,9 @@ impl WordApp { r } AfterPick::SaveAs(after) => { - let saved = picked.is_some_and(|path| { - self.run("file.save", json!({"path": path})) - .is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some()) - }); + // The save's error, kept for the Save Changes prompt to explain (#1445). + let result = picked.map(|path| self.run("file.save", json!({"path": path}))); + let saved = matches!(&result, Some(Ok(v)) if v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some()); if saved { self.ui.backstage = false; self.canvas.want_focus = true; @@ -255,6 +254,9 @@ impl WordApp { Ok(json!({"saved": saved})) } AfterSave::Continue { then, params, document } => { + if let Some(Err(error)) = result { + return Err(self.save_failed(&error, then, params, document)); + } if !self.safely_saved(saved) || document != self.session.document_id() { return Ok(json!({"done": false})); } @@ -442,6 +444,24 @@ mod tests { assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); } + /// #1445: "Save changes?" → Save on a new document while another file dialog is still open: + /// nothing can be asked, so nothing happens, and the status bar says why. + #[test] + fn save_changes_while_a_file_dialog_is_open_says_so() { + let (mut a, shown) = async_app(); + typed(&mut a); + a.open_dialog(); + assert_eq!(shown.borrow().len(), 1); + a.run("file.close", json!({})).unwrap(); + a.status_msg = None; + let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + assert_eq!(r["done"], false); + assert!(!a.quit_requested); + assert!(a.session.dirty && body_text(&a).contains(UNSAVED)); + let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(msg.contains("file dialog is already open"), "the status bar says why: {msg:?}"); + } + /// "Save changes?" → Save on a new document: the held-back Close runs only once the later /// Save As has actually saved; a cancelled Save As cancels the held-back New. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613eec399..687a0b479 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -651,9 +651,14 @@ impl WordApp { return Ok(json!({"pending": name})); } if self.session.dirty && discards_document(id, ¶ms) { - let name = - self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.title_stem()); - self.dialog = Some(dialogs::Dialog::SaveChanges { name, then: id.to_string(), params, document: self.session.document_id() }); + let name = self.prompt_name(); + self.dialog = Some(dialogs::Dialog::SaveChanges { + name, + then: id.to_string(), + params, + document: self.session.document_id(), + message: String::new(), + }); return Ok(json!({"pending": "saveChanges"})); } // The ribbon and context-menu Paste commands send no text: read the system clipboard's, as @@ -843,7 +848,10 @@ impl WordApp { file_dialogs::Asked::Busy => Ok(json!({"done": false})), }; } - SaveChoice::Save => self.save_for_prompt(), + SaveChoice::Save => match self.save_for_prompt() { + Ok(saved) => saved, + Err(error) => return Err(self.save_failed(&error, then, params, document)), + }, }; if !go { return Ok(json!({"done": false})); @@ -870,13 +878,33 @@ impl WordApp { /// Save before New/Open/Close (and the mailings that replace the document): true once the /// document is safely written. A save in a format that doesn't keep everything (a page /// image, plain text) writes a copy and leaves the document unsaved, so it doesn't count: - /// the command is cancelled and the document stays. - fn save_for_prompt(&mut self) -> bool { - let saved = match self.execute("file.save", json!({})) { - Ok(v) => v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(), - Err(_) => false, - }; - self.safely_saved(saved) + /// the command is cancelled and the document stays. A save that fails (no permission, the + /// file locked by another app, a missing folder) is the save's error ([`Self::save_failed`] + /// tells the user). + fn save_for_prompt(&mut self) -> Result { + let v = self.execute("file.save", json!({}))?; + let saved = v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(); + Ok(self.safely_saved(saved)) + } + + /// The document's name as the Save Changes prompt shows it: its file's name, else its title. + fn prompt_name(&self) -> String { + self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.title_stem()) + } + + /// Save (or Save As) from the Save Changes prompt failed (#1445): say so plainly on the status + /// bar and in the prompt, which comes back for Save again, Don't Save or Cancel, with the + /// held-back command (`then`) still waiting. Nothing closes and nothing is discarded. Returns + /// the message, the prompt answer's error. + pub(crate) fn save_failed(&mut self, error: &str, then: String, params: Value, document: u64) -> String { + let name = self.prompt_name(); + let message = i18n::fmt(tl!("Couldn't save {name}, so it is still open and nothing was lost: {error}"), &[("name", &name), ("error", error)]); + log::error!("save from the Save Changes prompt failed: {error}"); + self.status(message.clone()); + if document == self.session.document_id() { + self.dialog = Some(dialogs::Dialog::SaveChanges { name, then, params, document, message: message.clone() }); + } + message } /// Whether a save before New/Open/Close counts: it happened, and kept everything. @@ -2948,6 +2976,14 @@ mod tests { a.session.doc.plain_text(wordcraft_doc::StoryRef::Body) } + /// The reason the Save Changes prompt shows (a failed save), if that prompt is open. + fn prompt_message(a: &WordApp) -> Option { + match &a.dialog { + Some(dialogs::Dialog::SaveChanges { message, .. }) => Some(message.clone()), + _ => None, + } + } + fn prompt(a: &WordApp) -> Option<&'static str> { a.dialog.as_ref().map(|d| d.name()) } @@ -3091,6 +3127,114 @@ mod tests { assert!(body_text(&a).contains(UNSAVED), "nothing was replaced on the way out"); } + /// #1445: the window's X on an edited document with a file → Save writes the file and lets + /// the window close. + #[test] + fn window_close_save_writes_the_file_and_quits() { + let dir = scratch("close-save"); + let path = docx_on_disk(&dir, "Draft"); + let mut a = app(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + assert!(!a.close_requested()); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + let on_disk = wordcraft_engine::io::open_path(&path).unwrap().plain_text(wordcraft_doc::StoryRef::Body); + assert!(on_disk.contains("Revised Draft"), "the edit is on disk: {on_disk:?}"); + assert!(a.quit_requested, "the app may close"); + assert!(a.close_requested(), "the close that follows goes ahead"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1445: when Save in the close prompt can't write the file, the prompt's answer is an error + /// that says so plainly, the status bar shows it, and the document stays open, still edited. + #[test] + fn window_close_save_that_fails_says_so_and_keeps_the_document() { + let dir = scratch("close-save-fails"); + let folder = dir.join("folder"); + std::fs::create_dir_all(&folder).unwrap(); + let path = docx_on_disk(&folder, "Draft"); + let mut a = app(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + // The document's folder is gone and a file has its name: nothing can be written there. + std::fs::remove_dir_all(&folder).unwrap(); + std::fs::write(&folder, b"not a folder").unwrap(); + assert!(!a.close_requested()); + a.status_msg = None; + let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); + assert!(err.contains("Couldn't save contract.docx") && err.contains("still open"), "{err}"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| *m == err), "the status bar says why: {:?}", a.status_msg); + assert!(!a.quit_requested, "nothing closes"); + assert!(a.session.dirty && body_text(&a).contains("Revised Draft"), "nothing was discarded"); + assert_eq!(a.session.path.as_deref(), Some(path.as_path())); + // The prompt comes back with the reason in it: Save again, Don't Save or Cancel. + assert_eq!(prompt(&a), Some("saveChanges"), "the prompt is open again"); + assert_eq!(prompt_message(&a).as_deref(), Some(err.as_str()), "the prompt says why"); + assert!(a.run("ui.saveChanges", json!({"answer": "save"})).is_err(), "Save again fails again"); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert_eq!(prompt(&a), None); + assert!(!a.close_requested(), "the next close asks again"); + assert_eq!(prompt_message(&a).as_deref(), Some(""), "a fresh prompt, no old reason"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested, "Don't Save still closes"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1445: a new document → close → Save → Save As to a place that can't be written: the + /// same plain error, the prompt back with the reason, nothing closed or discarded. + #[test] + fn window_close_save_as_that_fails_says_so_and_keeps_the_document() { + let dir = scratch("close-save-as-fails"); + let blocker = dir.join("folder"); + std::fs::write(&blocker, b"not a folder").unwrap(); + let picked = blocker.join("novel.docx").to_string_lossy().to_string(); + let mut a = typed(); + a.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + assert!(!a.close_requested()); + a.status_msg = None; + let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); + assert!(err.contains("Couldn't save") && err.contains("still open"), "{err}"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| *m == err), "the status bar says why: {:?}", a.status_msg); + assert!(!a.quit_requested, "nothing closes"); + assert!(a.session.dirty && body_text(&a).contains(UNSAVED), "nothing was discarded"); + assert_eq!(a.session.path, None); + assert_eq!(prompt(&a), Some("saveChanges"), "the prompt is open again"); + assert_eq!(prompt_message(&a).as_deref(), Some(err.as_str()), "the prompt says why"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1445: the prompt that comes back after a failed save shows the reason on screen. + #[test] + fn save_prompt_shows_why_the_save_failed() { + use egui_kittest::kittest::Queryable; + let mut a = typed(); + a.run("file.close", json!({})).unwrap(); + let reason = "Couldn't save Draft, so it is still open and nothing was lost: disk full"; + if let Some(dialogs::Dialog::SaveChanges { message, .. }) = &mut a.dialog { + *message = reason.into(); + } + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().build_ui_state( + move |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + if fonts { + dialogs::show(app, &ctx); + } else { + theme::install_fonts_for(&ctx, false); + fonts = true; + } + }, + a, + ); + for _ in 0..3 { + h.step(); + } + assert!(h.query_by_label(reason).is_some(), "the reason is in the prompt"); + assert!(h.query_by_label("Save").is_some(), "Save again is offered"); + } + /// Enter activates the focused prompt button; it means Save only when no button has focus. /// (Enter on a focused Cancel or Don't Save used to be overridden by a blanket Enter → Save.) #[test] @@ -3799,8 +3943,9 @@ mod tests { a.run("file.new", json!({})).unwrap(); a.status_msg = None; - let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - assert_eq!(r["done"], false, "the New was cancelled"); + // A failed save is an error, not a quiet Cancel (#1445); the New doesn't run. + let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); + assert!(err.contains("still open") && err.contains("blocked by the browser"), "{err}"); assert!(body_text(&a).contains(UNSAVED)); assert!(a.session.dirty); let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); From 45d2546b63c0e7ef8d1d2c99ed7f8f6db10990a9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 19:20:31 +0200 Subject: [PATCH 055/199] Metafile: WMF object table without scanning, in-place shape ops, cropped DIB decode (#1463) (#1466) * Metafile: WMF handle table finds its lowest free slot without scanning (#1463) Co-Authored-By: Claude Sonnet 5.5 * Metafile: map shape ops in place and decode only the clipped part of a DIB (#1463) Co-Authored-By: Claude Sonnet 5.5 * ROADMAP: progress log row for the metafile reader (#1463) Co-Authored-By: Claude Sonnet 5.5 * Metafile: write cropped DIB pixels through as_chunks_mut (#1463) Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/metafile/src/canvas.rs | 140 ++++++++++++++++++++++-- crates/metafile/src/dib.rs | 193 +++++++++++++++++++++++++++++----- crates/metafile/src/emf.rs | 15 ++- crates/metafile/src/tests.rs | 6 +- crates/metafile/src/wmf.rs | 13 ++- 6 files changed, 317 insertions(+), 51 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 133a3ac31..5e2fe1263 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -158,6 +158,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole | | 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. | | 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | +| 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index fa8bfdcf9..0ac4790f6 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -190,6 +190,12 @@ pub(crate) struct Canvas { /// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`]. used: usize, objects: Vec>, + /// Indices below `objects.len()` that hold no object, so the WMF handle table finds its lowest + /// free slot without scanning. + free: std::collections::BTreeSet, + /// Table slots examined by `add_object(None, ..)`; the tests assert it stays constant per call. + #[cfg(test)] + pub slot_probes: usize, pen: Pen, brush: Brush, even_odd: bool, @@ -211,6 +217,9 @@ impl Canvas { saved: Vec::new(), used: 0, objects: Vec::new(), + free: std::collections::BTreeSet::new(), + #[cfg(test)] + slot_probes: 0, pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] }, brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, even_odd: true, @@ -328,20 +337,27 @@ impl Canvas { self.nop = s.nop; } - /// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table). + /// Stores an object in `slot` (EMF index), or in the lowest free slot (WMF handle table). pub fn add_object(&mut self, slot: Option, obj: Obj) { match slot { Some(i) if i < MAX_OBJECTS => { + // Growing the table leaves the slots between the old end and `i` free. + self.free.extend(self.objects.len()..i); if self.objects.len() <= i { self.objects.resize(i + 1, None); } + self.free.remove(&i); if let Some(s) = self.objects.get_mut(i) { *s = Some(obj); } } Some(_) => {} - None => match self.objects.iter().position(Option::is_none) { + None => match self.free.pop_first() { Some(i) => { + #[cfg(test)] + { + self.slot_probes += 1; + } if let Some(s) = self.objects.get_mut(i) { *s = Some(obj); } @@ -353,8 +369,10 @@ impl Canvas { } pub fn delete_object(&mut self, i: u32) { - if let Some(s) = self.objects.get_mut(i as usize) { + let i = i as usize; + if let Some(s) = self.objects.get_mut(i) { *s = None; + self.free.insert(i); } } @@ -421,8 +439,11 @@ impl Canvas { /// Draws logical-space ops with the current pen (`stroke`) and brush (`fill`), or appends them to /// the open path bracket, where fill and stroke wait for FILLPATH/STROKEPATH. - pub fn shape(&mut self, ops: &[Op], fill: bool, stroke: bool) { - let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + pub fn shape(&mut self, mut dev: Vec, fill: bool, stroke: bool) { + // Maps in place: the caller's vector becomes the device-space path, no second copy. + for op in &mut dev { + *op = self.map_op(*op); + } if self.in_path { return self.push_path_ops(dev); } @@ -458,14 +479,14 @@ impl Canvas { if let Some(last) = pts.last() { self.pos = *last; } - self.shape(&ops, fill, stroke); + self.shape(ops, fill, stroke); } /// POLYBEZIER: cubic segments that start a new figure at `from`. The current point is unchanged. pub fn bezier(&mut self, from: P, pts: &[P]) { let mut ops = vec![Op::M(from)]; ops.extend(cubics(pts)); - self.shape(&ops, false, true); + self.shape(ops, false, true); } /// POLYBEZIERTO: cubic segments from the current point through groups of three control or end @@ -477,7 +498,7 @@ impl Canvas { if let Some(last) = pts.last() { self.pos = *last; } - self.shape(&ops, false, true); + self.shape(ops, false, true); } pub fn poly(&mut self, pts: &[P], close: bool, fill: bool, stroke: bool) { @@ -487,7 +508,7 @@ impl Canvas { if close { ops.push(Op::Z); } - self.shape(&ops, fill, stroke); + self.shape(ops, fill, stroke); } pub fn begin_path(&mut self) { @@ -572,3 +593,104 @@ fn cubics(pts: &[P]) -> Vec { } ops } + +#[cfg(test)] +mod object_table_tests { + use super::*; + + fn pen(id: u32) -> Obj { + Obj::Pen(Pen { style: 0, width: 0.0, color: [id as u8, (id >> 8) as u8, 0, 255] }) + } + + fn id_of(o: &Option) -> Option { + match o { + Some(Obj::Pen(p)) => Some(u32::from(p.color[0]) | u32::from(p.color[1]) << 8), + _ => None, + } + } + + /// The table as it was before the free-slot set: a scan from index 0 for every handle-table create. + struct Naive(Vec>); + + impl Naive { + fn add(&mut self, slot: Option, id: u32) { + match slot { + Some(i) if i < MAX_OBJECTS => { + if self.0.len() <= i { + self.0.resize(i + 1, None); + } + self.0[i] = Some(id); + } + Some(_) => {} + None => match self.0.iter().position(Option::is_none) { + Some(i) => self.0[i] = Some(id), + None if self.0.len() < MAX_OBJECTS => self.0.push(Some(id)), + None => {} + }, + } + } + + fn delete(&mut self, i: u32) { + if let Some(s) = self.0.get_mut(i as usize) { + *s = None; + } + } + } + + #[test] + fn handle_table_picks_the_same_slots_as_the_first_free_scan() { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut rnd = move |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % n + }; + for round in 0..20 { + // Small tables exercise reuse; some rounds also run past the cap and use explicit EMF slots. + let span = if round % 2 == 0 { 12 } else { MAX_OBJECTS as u64 + 50 }; + let (mut cv, mut naive) = (Canvas::new(), Naive(Vec::new())); + for id in 0..20_000u32 { + match rnd(10) { + 0..=4 => { + cv.add_object(None, pen(id)); + naive.add(None, id); + } + 5 | 6 if round >= 10 => { + let slot = rnd(span + 20) as usize; + cv.add_object(Some(slot), pen(id)); + naive.add(Some(slot), id); + } + _ => { + let i = rnd(span + 5) as u32; + cv.delete_object(i); + naive.delete(i); + } + } + if id % 500 == 0 || id == 19_999 { + let got: Vec> = cv.objects.iter().map(id_of).collect(); + assert_eq!(got, naive.0, "round {round}, step {id}"); + } + } + } + } + + #[test] + fn creating_into_a_full_table_does_not_scan_it() { + let mut cv = Canvas::new(); + for i in 0..MAX_OBJECTS as u32 { + cv.add_object(None, pen(i)); + } + assert_eq!(cv.objects.len(), MAX_OBJECTS); + let before = cv.slot_probes; + for i in 0..10_000 { + cv.add_object(None, pen(i)); + } + // A scan from index 0 would examine 4096 slots per create; the free set examines none. + assert_eq!(cv.slot_probes, before); + cv.delete_object(7); + cv.add_object(None, pen(99)); + assert_eq!(cv.slot_probes, before + 1); + assert_eq!(cv.objects.get(7).map(id_of), Some(Some(99))); + } +} diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs index 55cf091a0..6683999eb 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -11,11 +11,10 @@ const ROP_SRCAND: u32 = 0x0088_00C6; const ROP_SRCPAINT: u32 = 0x00EE_0086; const ROP_SRCINVERT: u32 = 0x0066_0046; -/// A decoded picture. `bottom_up` records the DIB's native row order, which source rectangles use. +/// A decoded picture, rows top-down. pub(crate) struct Rgba { pub w: usize, pub h: usize, - pub bottom_up: bool, pub px: Vec, } @@ -83,10 +82,13 @@ pub(crate) fn info_len(dib: Bytes) -> Option { h.pal_off.checked_add(h.pal_n.checked_mul(4)?) } -/// Decodes a DIB given as its info part (header plus masks and palette) and its pixel bits. The +/// Decodes the part of a DIB that the source rectangle `src` = `[x, y, w, h]` selects and returns it +/// (top-down) with the part of the destination `dst` that it fills; see [`clip_rect`] for the +/// coordinates. `info` is the info part (header plus masks and palette) and `bits` the pixel bits. The /// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels -/// are opaque. -pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option { +/// are opaque. Only the clipped rows and columns are decoded, so the work and the memory follow the +/// crop, not the whole bitmap; the whole bitmap is still validated (size cap, palette, bits length). +pub(crate) fn decode_clip(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { let hd = Header::read(info)?; let pixels = hd.w.checked_mul(hd.h)?; if pixels == 0 || pixels > MAX_PIXELS { @@ -95,20 +97,28 @@ pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4; let data = bits.slice(0, stride.checked_mul(hd.h)?)?; let pal = info.slice(hd.pal_off, hd.pal_n * 4)?; - let mut px = vec![0u8; pixels * 4]; - for y in 0..hd.h { + let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?; + let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0); + let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?]; + for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) { let src_row = if hd.top_down { y } else { hd.h - 1 - y }; let row = src_row * stride; - for x in 0..hd.w { - let p = pixel(data, row, x, &hd, pal, keep_alpha)?; - let o = (y * hd.w + x) * 4; - px.get_mut(o..o + 4)?.copy_from_slice(&p); + for (x, dst_px) in (clip.x0..clip.x1).zip(line.as_chunks_mut::<4>().0) { + *dst_px = pixel(data, row, x, &hd, pal, keep_alpha)?; } } - Some(Rgba { w: hd.w, h: hd.h, bottom_up: !hd.top_down, px }) + Some((Rgba { w: ow, h: oh, px }, out)) +} + +#[cfg(test)] +thread_local! { + /// Pixels decoded on this thread, so the tests can check that the work follows the crop. + static DECODED: std::cell::Cell = const { std::cell::Cell::new(0) }; } fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> { + #[cfg(test)] + DECODED.with(|d| d.set(d.get() + 1)); let [m_r, m_g, m_b] = hd.masks; let [r, g, b] = match hd.bpp { 1 => { @@ -155,16 +165,25 @@ fn field(v: u32, mask: u32) -> u8 { ((val * 255 + max / 2) / max) as u8 } -/// Clips a source rectangle `src` = `[x, y, w, h]` to the bitmap and returns the cropped pixels (top-down) +/// The columns `x0..x1` and top-down rows `r0..r1` of a bitmap that a clipped source rectangle covers. +struct Clip { + x0: usize, + x1: usize, + r0: usize, + r1: usize, +} + +/// Clips a source rectangle `src` = `[x, y, w, h]` to a `w` x `h` bitmap and returns the covered pixels /// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted /// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the /// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part, /// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors). -pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { - let (iw, ih) = (img.w as i64, img.h as i64); +/// `None` when nothing of the bitmap is covered. +fn clip_rect(w: usize, h: usize, bottom_up: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Clip, [f64; 4])> { + let (iw, ih) = (w as i64, h as i64); let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] }; // The request's top row in top-down order. - let top = if img.bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy }; + let top = if bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy }; let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw)); let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih)); if x1 <= x0 || r1 <= r0 { @@ -172,14 +191,7 @@ pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgb } let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64); let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky]; - let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize); - let (ow, oh) = (x1 - x0, r1 - r0); - let mut px = Vec::with_capacity(ow * oh * 4); - for row in r0..r1 { - let s = (row * img.w + x0) * 4; - px.extend_from_slice(img.px.get(s..s + ow * 4)?); - } - Some((Rgba { w: ow, h: oh, bottom_up: false, px }, out)) + Some((Clip { x0: x0 as usize, x1: x1 as usize, r0: r0 as usize, r1: r1 as usize }, out)) } /// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so @@ -257,3 +269,136 @@ fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) { top.swap_with_slice(bottom); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The pipeline before `decode_clip`: decode the whole bitmap, then copy the clipped part out. + fn decode_whole(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option { + let hd = Header::read(info)?; + let pixels = hd.w.checked_mul(hd.h)?; + if pixels == 0 || pixels > MAX_PIXELS { + return None; + } + let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4; + let data = bits.slice(0, stride.checked_mul(hd.h)?)?; + let pal = info.slice(hd.pal_off, hd.pal_n * 4)?; + let mut px = vec![0u8; pixels * 4]; + for y in 0..hd.h { + let src_row = if hd.top_down { y } else { hd.h - 1 - y }; + let row = src_row * stride; + for x in 0..hd.w { + let p = pixel(data, row, x, &hd, pal, keep_alpha)?; + let o = (y * hd.w + x) * 4; + px.get_mut(o..o + 4)?.copy_from_slice(&p); + } + } + Some(Rgba { w: hd.w, h: hd.h, px }) + } + + fn clip_blit_whole(img: &Rgba, bottom_up: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { + let (iw, ih) = (img.w as i64, img.h as i64); + let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] }; + let top = if bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy }; + let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw)); + let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih)); + if x1 <= x0 || r1 <= r0 { + return None; + } + let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64); + let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky]; + let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize); + let (ow, oh) = (x1 - x0, r1 - r0); + let mut px = Vec::with_capacity(ow * oh * 4); + for row in r0..r1 { + let s = (row * img.w + x0) * 4; + px.extend_from_slice(img.px.get(s..s + ow * 4)?); + } + Some((Rgba { w: ow, h: oh, px }, out)) + } + + /// A BITMAPINFOHEADER DIB with a palette for indexed depths; returns (info, bits). + fn make_dib(w: usize, h: i32, bpp: usize, seed: &mut u64) -> (Vec, Vec) { + let mut rnd = || { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + }; + let mut info = Vec::new(); + info.extend(40u32.to_le_bytes()); + info.extend((w as i32).to_le_bytes()); + info.extend(h.to_le_bytes()); + info.extend(1u16.to_le_bytes()); + info.extend((bpp as u16).to_le_bytes()); + info.extend([0u8; 24]); + if bpp <= 8 { + for _ in 0..(1usize << bpp) * 4 { + info.push(rnd() as u8); + } + } + let stride = (w * bpp).div_ceil(32) * 4; + let bits = (0..stride * h.unsigned_abs() as usize).map(|_| rnd() as u8).collect(); + (info, bits) + } + + #[test] + fn decode_clip_matches_decode_then_clip_blit() { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut cases = 0; + for bpp in [1, 4, 8, 16, 24, 32] { + for h in [5, -5, 1, -1] { + let w = 7; + let (info, bits) = make_dib(w, h, bpp, &mut seed); + for keep_alpha in [false, true] { + // Source x/y from outside the bitmap on both sides; sizes include zero and negative. + for sx in [-9, -2, 0, 3, 6, 8, 20] { + for sy in [-9, -2, 0, 2, 4, 5, 20] { + for (sw, sh) in [(0, 0), (-3, 4), (4, -2), (1, 1), (3, 2), (7, 5), (30, 30), (2, 9)] { + let src = [sx, sy, sw, sh]; + let dst = [10.0, 20.0, 70.0, 50.0]; + let old = decode_whole(Bytes(&info), Bytes(&bits), keep_alpha).and_then(|img| clip_blit_whole(&img, h > 0, src, dst)); + let new = decode_clip(Bytes(&info), Bytes(&bits), keep_alpha, src, dst); + match (old, new) { + (None, None) => {} + (Some((a, ad)), Some((b, bd))) => { + assert_eq!((a.w, a.h, &a.px), (b.w, b.h, &b.px), "{bpp} bpp h={h} {src:?}"); + assert_eq!(ad.map(f64::to_bits), bd.map(f64::to_bits), "{bpp} bpp h={h} {src:?}"); + } + _ => panic!("old and new disagree on None: {bpp} bpp h={h} {src:?}"), + } + cases += 1; + } + } + } + } + } + } + assert!(cases > 10_000); + } + + #[test] + fn decode_clip_rejects_what_decode_rejected() { + let mut seed = 7; + let (info, bits) = make_dib(7, 5, 24, &mut seed); + let short = &bits[..bits.len() - 1]; + // A short bits buffer fails the whole image even when the crop lies in the rows that exist. + assert!(decode_clip(Bytes(&info), Bytes(short), false, [0, 0, 1, 1], [0.0, 0.0, 1.0, 1.0]).is_none()); + assert!(decode_whole(Bytes(&info), Bytes(short), false).is_none()); + } + + #[test] + fn decode_clip_decodes_only_the_cropped_pixels() { + let mut seed = 99; + let (info, bits) = make_dib(400, -300, 24, &mut seed); + DECODED.with(|d| d.set(0)); + let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [10, 20, 8, 4], [0.0, 0.0, 8.0, 4.0]).unwrap(); + assert_eq!((img.w, img.h), (8, 4)); + assert_eq!(DECODED.with(|d| d.get()), 8 * 4); + // The whole-bitmap path decodes all 400 x 300. + DECODED.with(|d| d.set(0)); + decode_whole(Bytes(&info), Bytes(&bits), false).unwrap(); + assert_eq!(DECODED.with(|d| d.get()), 400 * 300); + } +} diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 027cfae9f..904d126b7 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -103,13 +103,13 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 40 => r.u32(8).map(|i| cv.delete_object(i)), 27 => xy32(r, 8).map(|p| cv.move_to(p)), 54 => xy32(r, 8).map(|p| cv.line_to(p)), - 42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)), - 43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)), + 42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(geom::ellipse(l, t, rr, b), true, true)), + 43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(geom::rect(l, t, rr, b), true, true)), 44 => { let (cw, ch) = (r.i32(24), r.i32(28)); let (Some(cw), Some(ch)) = (cw, ch) else { return }; rect32(r, 8).map(|[l, t, rr, b]| { - cv.shape(&geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true); + cv.shape(geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true); }) } 45..=47 => arc32(r).map(|(bounds, s, e)| { @@ -119,7 +119,7 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { _ => ArcKind::Pie, }; let [l, t, rr, b] = bounds; - cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); + cv.shape(geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); }), 4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)), 3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)), @@ -132,8 +132,8 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { Some(()) }), 5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)), - 7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, false), false, true)), - 8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, true), true, true)), + 7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(geom::subpaths(&pts, &counts, false), false, true)), + 8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(geom::subpaths(&pts, &counts, true), true, true)), 59 => { cv.begin_path(); Some(()) @@ -307,9 +307,8 @@ fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usi } let info = Bytes(r.slice(bo, bl)?); let pixels = Bytes(r.slice(po, pl)?); - let mut img = dib::decode(info, pixels, blend.per_pixel())?; + let (mut img, dst) = dib::decode_clip(info, pixels, blend.per_pixel(), src.map(|v| v as i64), dst)?; dib::prepare(&mut img, blend); - let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?; cv.bitmap(dst, img); Some(()) } diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 4ee32578e..8a93e1411 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -468,7 +468,7 @@ fn emf_savedc_past_the_stack_cap_stops_the_parse() { fn canvas_stops_at_the_byte_budget() { // Two 128 MB bitmaps fit the budget; the third would exceed it and stops the canvas. let mut cv = Canvas::new(); - let big = || Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; MAX_BYTES / 2] }; + let big = || Rgba { w: 1, h: 1, px: vec![0u8; MAX_BYTES / 2] }; cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); assert!(!cv.full()); @@ -866,11 +866,11 @@ fn emf_ext_pen_cosmetic_is_a_hairline_and_null_brush_draws_nothing() { #[test] fn flip_tolerates_a_short_pixel_buffer() { - let mut img = Rgba { w: 2, h: 2, bottom_up: false, px: vec![1u8; 7] }; + let mut img = Rgba { w: 2, h: 2, px: vec![1u8; 7] }; crate::dib::flip(&mut img, true, true); assert_eq!(img.px.len(), 7); // Three rows held of four: only the rows present swap (0 with 2). - let mut img = Rgba { w: 1, h: 4, bottom_up: false, px: (0u8..12).collect() }; + let mut img = Rgba { w: 1, h: 4, px: (0u8..12).collect() }; crate::dib::flip(&mut img, false, true); assert_eq!(img.px, [8, 9, 10, 11, 4, 5, 6, 7, 0, 1, 2, 3]); } diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index d221a7655..54c14271f 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -107,16 +107,16 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) { 0x0213 => xy16(r, 6).map(|p| cv.line_to(p)), 0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)), 0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)), - 0x0538 => polypolygon(r).map(|ops| cv.shape(&ops, true, true)), - 0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)), + 0x0538 => polypolygon(r).map(|ops| cv.shape(ops, true, true)), + 0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(geom::rect(l, t, rr, b), true, true)), 0x061C => { let [ew, eh] = [r.i16(8), r.i16(6)]; let (Some(ew), Some(eh)) = (ew, eh) else { return }; rect16(r, 10).map(|[l, t, rr, b]| { - cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true); + cv.shape(geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true); }) } - 0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)), + 0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(geom::ellipse(l, t, rr, b), true, true)), 0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| { let kind = match func { 0x0817 => ArcKind::Arc, @@ -124,7 +124,7 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) { _ => ArcKind::Chord, }; let [l, t, rr, b] = bounds; - cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); + cv.shape(geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); }), 0x02FA => create_pen(cv, r), 0x02FC => create_brush(cv, r), @@ -265,9 +265,8 @@ fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: let n = dib::info_len(dib)?; let info = dib.slice(0, n)?; let bits = dib.tail(n)?; - let mut img = dib::decode(Bytes(info), bits, false)?; + let (mut img, dst) = dib::decode_clip(Bytes(info), bits, false, src.map(|v| v as i64), dst)?; dib::prepare(&mut img, dib::Blend::Rop(rop)); - let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?; cv.bitmap(dst, img); Some(()) } From 26cdede600cb5df0dd60c29e3e30986f71d9bac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bihacsy=20L=C3=A1szl=C3=B3?= <115165402+Skaywalker@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:50 +0200 Subject: [PATCH 056/199] i18n(hu): align two new rows with the catalog terms (#1469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Title Bar" was "Címsor", the word the catalog uses for Heading (Címsor 1, Címsor 2); it is now "Címsáv". "AutoFit Column by Double-Clicking" now uses "automatikus méretezés", as AutoFit reads elsewhere in the catalog. Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/hu.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2bad2faaf..b57039465 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2464,11 +2464,10 @@ Select Table Cells by Clicking Táblázatcellák kijelölése kattintással Insert Row or Column by Clicking Sor vagy oszlop beszúrása kattintással Move Table by Dragging Táblázat áthelyezése húzással - AutoFit Column by Double-Clicking Oszlop automatikus igazítása dupla kattintással + AutoFit Column by Double-Clicking Oszlop automatikus méretezése dupla kattintással Insert Caption… Felirat beszúrása… Table Properties… Táblázat tulajdonságai… Search Keresés - Title Bar Címsor Privacy Options Adatvédelmi beállítások Trust Center Adatvédelmi központ Privacy for this document A dokumentum adatvédelme @@ -2503,3 +2502,4 @@ Suggested Javaslatok Set Proofing Language… Nyelvi ellenőrzés nyelvének beállítása… Language Preferences… Nyelvi beállítások… + Title Bar Címsáv From 47ae2d495dc803ba4c33b4823f7de6313a7f05fd Mon Sep 17 00:00:00 2001 From: dfgodvan Date: Wed, 14 Oct 2026 20:21:03 +0300 Subject: [PATCH 057/199] References: TOC entries carry their headings' list numbers (#1470) Update Table took an entry's text from the heading's plain text, which has no list label, so headings numbered through a multilevel list (1, 1.1) lost their numbers in the TOC. The entries now walk the body's list counters and put the label and the level's separator (tab, space or nothing) before the text, as Word writes it. Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/references.rs | 22 ++++++++++++++++++++-- crates/engine/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index c16ded371..4b2a09fd6 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -1,8 +1,10 @@ //! References tab: table of contents, footnotes/endnotes, captions; field updates. use serde_json::{Value, json}; +use wordcraft_doc::numbering::{Counters, LevelSuffix}; use wordcraft_doc::para::{InlineObject, NoteKind, OBJ}; use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop}; +use wordcraft_doc::section::NumFormat; use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, para_block}; use super::{delete_selection, sel_result}; @@ -244,11 +246,26 @@ fn toc_entries(s: &Session, f: &FieldCode) -> Vec { let tc = f.has('f').then(|| f.arg('f').unwrap_or("").to_ascii_uppercase()); let tc_levels = f.arg('l').and_then(level_range).unwrap_or((1, 9)); let mut v = Vec::new(); + // Every numbered paragraph advances the list counters, so an entry carries the number its + // heading shows (`1.1`), followed by the level's separator, as Word writes it. + let mut counters = Counters::default(); for path in s.doc.para_paths(StoryRef::Body) { + let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + let number = p + .props + .numbering + .or_else(|| s.doc.styles.resolve_para(&p.props).numbering) + .filter(|n| n.num != 0) + .and_then(|n| counters.next_label(&s.doc.numbering, n.num, n.level)) + .filter(|(label, def)| !label.is_empty() && !matches!(def.format, NumFormat::Bullet | NumFormat::None)) + .map(|(label, def)| match def.suffix { + LevelSuffix::Tab => format!("{label}\t"), + LevelSuffix::Space => format!("{label} "), + LevelSuffix::Nothing => label, + }); if path.depth() > 0 { continue; } - let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; let style = p.props.style.as_deref().unwrap_or("Normal"); if style.starts_with("TOC") { continue; @@ -272,7 +289,8 @@ fn toc_entries(s: &Session, f: &FieldCode) -> Vec { if let Some(l) = by_style.or(by_outline).or(if hidden { None } else { by_heading }) { let t = p.plain_text().replace(['\u{000C}', '\n', '\t'], " ").trim().to_string(); if !t.is_empty() { - v.push(TocEntry { path: path.clone(), level: l - 1, text: t, lead }); + let text = number.map_or_else(|| t.clone(), |n| format!("{n}{t}")); + v.push(TocEntry { path: path.clone(), level: l - 1, text, lead }); } } if let Some(id) = &tc { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 143cc4721..476d2bc9c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1938,6 +1938,32 @@ fn toc_page_numbers_follow_headings() { } } +/// Headings numbered through a list carry their numbers, and the level's separator, into the +/// TOC entries. +#[test] +fn toc_entries_carry_heading_numbers() { + let mut s = s(); + run(&mut s, "references.toc", json!({})); + run(&mut s, "caret.docEnd", json!({})); + for name in ["Alpha", "Bravo"] { + run(&mut s, "text.pageBreak", json!({})); + run(&mut s, "text.insert", json!({"text": name})); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "text.newParagraph", json!({})); + } + let num = s.doc.numbering.add_list(wordcraft_doc::ListKind::Numbered); + for b in s.doc.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) + && p.props.style.as_deref() == Some("Heading1") + { + p.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 }); + } + } + run(&mut s, "references.updateToc", json!({})); + let t = text(&s); + assert!(t.contains("1.\tAlpha\t2") && t.contains("2.\tBravo\t3"), "{t}"); +} + /// A heading above the TOC keeps its index when the entries go in below it, so its page is read /// where it is, not `entries` blocks further down. #[test] From 5ff69b8bab498b62197847148429c95c93455fa2 Mon Sep 17 00:00:00 2001 From: cyril265 <3495565+cyril265@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:21:21 +0200 Subject: [PATCH 058/199] PDF: draw horizontal and vertical lines inside groups (#1474) * PDF: draw horizontal and vertical lines inside groups A line or connector inside a group keeps its zero height or width (a:ext cy="0"), and the PDF export skipped every shape without area, so dividers drawn in Word groups were missing from exported PDFs while the screen showed them. * PDF: test hostile sizes for flat lines * Docs: progress log and file-format parity for lines inside groups in PDF export --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- crates/pdf/src/lib.rs | 4 +++- crates/pdf/src/tests.rs | 48 ++++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 ++-- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5e2fe1263..b34231de7 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: a failed save from the close prompt says so, #1445; 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: lines inside groups in PDF export, #1474; previously trivial: progress log: 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. @@ -159,6 +159,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. | | 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) | +| 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -314,6 +315,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: space after a page break | | 2026-10-13 | trivial | Progress log: table row margins and splitting | | 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | +| 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 6b370fbf9..e9d135a01 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -405,8 +405,10 @@ fn shape_effects(s: &mut Surface, kind: ShapeKind, rect: &Rect, fx: &wordcraft_d } /// Outline of a basic shape in a rectangle (same geometry as the raster renderer). +/// A line or connector may be flat (zero width or height: horizontal or vertical), not both. fn shape_path(kind: ShapeKind, r: &Rect) -> Option { - if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || r.w <= 0.0 || r.h <= 0.0 { + let flat_ok = kind.is_open() && r.w.max(r.h) > 0.0 && r.w.min(r.h) >= 0.0; + if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || !(flat_ok || r.w > 0.0 && r.h > 0.0) { return None; } let (x0, y0, x1, y1) = (r.x, r.y, r.x + r.w, r.y + r.h); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 5ce20dd58..ebf4a10c0 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -431,6 +431,17 @@ fn shapes_have_paths() { ] { assert!(shape_path(k, &r).is_some(), "{k:?}"); } + // A line or connector may be flat (horizontal or vertical); a closed shape may not. + for k in [ShapeKind::Line, ShapeKind::StraightConnector] { + assert!(shape_path(k, &Rect::new(0.0, 5.0, 10.0, 0.0)).is_some(), "{k:?} horizontal"); + assert!(shape_path(k, &Rect::new(5.0, 0.0, 0.0, 10.0)).is_some(), "{k:?} vertical"); + assert!(shape_path(k, &Rect::new(5.0, 5.0, 0.0, 0.0)).is_none(), "{k:?} without length"); + // Hostile sizes reach `shape_path` unclamped through the public fields. + for (w, h) in [(f32::NAN, 0.0), (f32::INFINITY, 0.0), (10.0, f32::NEG_INFINITY), (-10.0, 0.0), (0.0, -10.0), (10.0, -1.0)] { + assert!(shape_path(k, &Rect { x: 0.0, y: 0.0, w, h }).is_none(), "{k:?} {w} x {h}"); + } + } + assert!(shape_path(ShapeKind::Rectangle, &Rect::new(0.0, 5.0, 10.0, 0.0)).is_none()); assert!(shape_path(ShapeKind::Ellipse, &Rect::new(0.0, 0.0, f32::NAN, 1.0)).is_none()); assert_eq!(parse_iso("2024-02-03T04:05:06Z"), Some((2024, 2, 3, 4, 5, 6))); assert_eq!(parse_iso("garbage"), None); @@ -559,6 +570,43 @@ fn unsubsettable_font_is_drawn_as_outlines() { assert!(paths > 0, "its glyphs are drawn as outlines"); } +#[test] +fn flat_lines_in_a_group_are_drawn() { + // A horizontal divider inside a group has no height (`a:ext cy="0"`): it used to be left out + // of the PDF, though the screen drew it. A floating line on its own gets a 1 pt frame. + use wordcraft_doc::para::{Anchor, Float, GroupChild, ShapeKind, Wrap}; + let line = |w: f32, h: f32| InlineObject::Shape { + kind: ShapeKind::Line, + w, + h, + fill: None, + stroke: Some(wordcraft_doc::props::Rgb(192, 0, 0)), + stroke_width: 1.5, + float: Float::default(), + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + let doc = |children: Vec| { + let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 200.0, ..Default::default() }; + let group = InlineObject::Group { w: 300.0, h: 100.0, float, ch_w: 300.0, ch_h: 100.0, children, canvas: None }; + let mut p = Paragraph::with_text("Anchor", CharProps::default()); + p.insert_object(0, group, &CharProps::default()).unwrap(); + let mut d = Document::new(); + d.body = vec![para_block(p)]; + d + }; + let paths = |d: &Document| count_glyphs_and_paths(&export(d, &PdfOptions { tagged: false, ..Default::default() }).unwrap()).1; + let empty = paths(&doc(Vec::new())); + let flat = paths(&doc(vec![ + GroupChild { x: 10.0, y: 50.0, obj: line(280.0, 0.0) }, + GroupChild { x: 150.0, y: 0.0, obj: line(0.0, 100.0) }, + GroupChild { x: 20.0, y: 20.0, obj: line(0.0, 0.0) }, + ])); + assert_eq!(flat, empty + 2, "the horizontal and the vertical line, not the one without length"); +} + #[test] fn field_results_are_extractable_text() { // Issue #97 (test from #112 by @LloydNicholson): a REF field's result, not the U+FFFC diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 641da4f75..49c6777fc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-13 · **Change:** 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; @@ -23,7 +23,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.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` (56) | 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]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer | -| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice | +| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | @@ -167,6 +167,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | +| 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | From b8b8ff61289a55a2f381f444a14858d5bfa566f0 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:25:58 +0200 Subject: [PATCH 059/199] Layout: PAGE fields on the later pages of a paragraph show their own page (#1352) (#1481) * #1352: PAGE fields on the pages a paragraph runs onto show that page's number Co-Authored-By: Claude Opus 5.5 * #1352: keep the page map local; assert no overflow Co-Authored-By: Claude Opus 5.5 * Layout: re-laying out a paragraph keeps its equation numbers Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/layout/src/fields.rs | 15 ++- crates/layout/src/lib.rs | 89 +++++++++++++-- crates/layout/src/para.rs | 4 +- crates/layout/src/tests.rs | 216 ++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +- 6 files changed, 320 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b34231de7..a41cd1834 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: lines inside groups in PDF export, #1474; previously trivial: progress log: 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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. @@ -160,6 +160,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) | | 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height | +| 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -316,6 +317,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: table row margins and splitting | | 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) | +| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index b2d516fd7..3c71a836b 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -22,6 +22,10 @@ pub struct FieldCtx { pub title: Arc, pub author: Arc, pub filename: Arc, + /// Where a paragraph runs onto later pages: from text offset `.0` on, its fields are on page + /// `.1` (ascending offsets). Empty except during the layout of a body paragraph split across + /// pages, for its later pages (#1352); nothing else laid out meanwhile sees it. + pub page_from: Vec<(usize, u32)>, } /// A footnote's or endnote's number, as its section's options give it. @@ -43,6 +47,10 @@ impl FieldCtx { None => fmt.format(1), } } + /// The page a field at text offset `off` of the paragraph being laid out is on. + pub fn page_at(&self, off: usize) -> u32 { + self.page_from.iter().rev().find(|(from, _)| *from <= off).map_or(self.page, |(_, page)| *page) + } /// A key for caches: the parts of the context a page-dependent paragraph depends on. pub fn page_key(&self) -> (u32, u32, u32, u32) { (self.page, self.pages, self.section_pages, self.section) @@ -90,10 +98,15 @@ pub fn field_code_text(instr: &str) -> String { /// Display text for a field and whether it depends on the page it lands on. pub fn field_text(instr: &str, result: &str, ctx: &FieldCtx) -> (String, bool) { + field_text_on(instr, result, ctx, ctx.page) +} + +/// Display text for a field on page `page` (see [`field_text`]). +pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (String, bool) { let name = field_name(instr); let fmt = format_switch(instr); match name.as_str() { - "PAGE" => (fmt.unwrap_or(ctx.page_format).format(ctx.page), true), + "PAGE" => (fmt.unwrap_or(ctx.page_format).format(page), true), "NUMPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.pages), true), "SECTIONPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section_pages), true), "SECTION" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section), true), diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 9ebd0a6d5..053210ec1 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -462,6 +462,8 @@ struct Key { width: u32, label: Option, page: Option<(u32, u32, u32, u32)>, + /// The pages a split paragraph's fields are on after its first (`FieldCtx::page_from`). + page_from: u64, table: u64, notes: u64, excl: u64, @@ -541,6 +543,11 @@ fn has_page_fields(p: &Paragraph) -> bool { }) } +/// Does `p` show a PAGE field, whose result is the number of the page it lands on? +fn has_page_number_fields(p: &Paragraph) -> bool { + p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, .. } if fields::field_name(instr) == "PAGE")) +} + /// Does `p` show a page total, which depends on how many pages the document has? fn has_total_fields(p: &Paragraph) -> bool { p.objects.iter().any(|o| match o { @@ -717,6 +724,7 @@ impl Ctx<'_> { rev: p.rev, width: width.to_bits(), label: label.as_ref().map(|(t, l)| format!("{t}|{}|{}|{:?}|{:?}|{:?}|{:?}", l.indent, l.hanging, l.suffix, l.align, l.tab, l.chr)), + page_from: if page.is_some() && !self.fields.page_from.is_empty() { hash_of(&self.fields.page_from) } else { 0 }, page, table: table.map(|t| hash_of(&format!("{t:?}"))).unwrap_or(0), notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 }, @@ -2549,7 +2557,7 @@ fn anchor_floats( before: f32, excl_mark: usize, label: &Option<(String, Level)>, -) -> (HashMap, Arc) { +) -> (HashMap, Arc, Vec) { pb.excl.truncate(excl_mark); ctx.fields.page = pb.number; let (col_x, width) = (pb.col_x(), pb.col_w()); @@ -2574,7 +2582,8 @@ fn anchor_floats( } } let rel = rel_exclusions(&pb.excl, col_x, y0); - (rects, ctx.para_labelled(p, width, None, &rel, label.clone())) + let pl = ctx.para_labelled(p, width, None, &rel, label.clone()); + (rects, pl, rel) } /// Where a paragraph's floating objects are positioned from, when they aren't placed already: @@ -2711,6 +2720,28 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, place_para_in_flow(ctx, pb, p, block, body_top); } +/// How many times one body paragraph is laid out again for the page numbers of the later pages +/// it runs onto (#1352). +const MAX_PAGE_RELAYS: usize = 64; + +/// Run `f`, a layout of a paragraph again, with the automatic equation numbers it counted from +/// (`eq_start`) the first time, and leave the count where it was: laying it out again doesn't +/// count its numbered equations twice. +fn again(ctx: &mut Ctx, eq_start: u32, f: impl FnOnce(&mut Ctx) -> T) -> T { + let now = std::mem::replace(&mut ctx.eq_count, eq_start); + let out = f(ctx); + ctx.eq_count = now; + out +} + +/// Run `f` with `map` as the field context's page map (`FieldCtx::page_from`), then take it back. +fn with_page_map(ctx: &mut Ctx, map: &mut Vec<(usize, u32)>, f: impl FnOnce(&mut Ctx) -> T) -> T { + ctx.fields.page_from = std::mem::take(map); + let out = f(ctx); + *map = std::mem::take(&mut ctx.fields.page_from); + out +} + fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) { let width = pb.col_w(); // Floating objects anchored here join the page's wrap areas before the text is laid out; if @@ -2720,7 +2751,17 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let excl_mark = pb.excl.len(); let guess = pb.y + space_before; let label = ctx.next_label(p); - let (mut float_rects, mut pl) = anchor_floats(ctx, pb, p, guess, space_before, excl_mark, &label); + // The pages this paragraph's fields are on after its first (`FieldCtx::page_from`), kept here + // and lent to the field context only while this paragraph itself is laid out, so footnotes, + // text boxes and headers laid out in between never see it (#1352). + let mut page_map: Vec<(usize, u32)> = Vec::new(); + // Where its automatic equation numbers start: each layout of it again counts from here. + let eq_start = ctx.eq_count; + let (mut float_rects, mut pl, mut rel) = anchor_floats(ctx, pb, p, guess, space_before, excl_mark, &label); + // The page its fields were laid out for (and the width), and how many times it was laid out again for a later + // page (bounded: a paragraph over hundreds of pages keeps the last number it got). + let (mut laid_page, mut laid_width) = (pb.number, pb.col_w()); + let mut page_relays = 0usize; // The anchor paragraph's top (above its space before) the floats were placed from. let mut anchor_y = pb.y; let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col); @@ -2796,7 +2837,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: // A different top only matters when there is something to wrap around. if spot(pb) != placed_at || ((pb.y - guess).abs() > 0.01 && !pb.excl.is_empty()) { let y0 = pb.y; - (float_rects, pl) = anchor_floats(ctx, pb, p, y0, applied_before, excl_mark, &label); + (float_rects, pl, rel) = again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, p, y0, applied_before, excl_mark, &label)); + (laid_page, laid_width) = (pb.number, pb.col_w()); anchor_y = y0 - applied_before; } let mut n = pl.lines.len(); @@ -2804,6 +2846,36 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let mut after_on_page: Option = None; let mut l0 = 0; while l0 < n { + // Page fields in the lines going onto a new page show that page's number: lay the + // paragraph out again with the fields from there on on this page (the lines before stay as + // they were, so the line the rest starts with is still there), breaking the rest again for + // the new results' widths (#1352). Only a paragraph with page fields pays for this. + if l0 > 0 + && pl.has_page_fields + && !pb.web + && page_relays < MAX_PAGE_RELAYS + && let Some(resume) = pl.lines.get(l0).map(|l| l.start) + && page_map.last().map_or(laid_page, |e| e.1) != pb.number + && has_page_number_fields(p) + { + page_relays += 1; + page_map.push((resume, pb.number)); + ctx.fields.page = laid_page; + let relaid = + with_page_map(ctx, &mut page_map, |ctx| again(ctx, eq_start, |ctx| ctx.para_labelled(p, laid_width, None, &rel, label.clone()))); + match relaid.lines.iter().position(|l| l.start == resume) { + Some(k) => { + pl = relaid; + n = pl.lines.len(); + l0 = k; + } + None => { + page_map.pop(); + } + } + } + // What is laid out for these lines from here (their footnotes) is on this page. + ctx.fields.page = pb.number; // How many lines fit? let Some(first) = pl.lines.get(l0) else { break }; // A paragraph's first line may start below its top (pushed down by a floating object). @@ -2906,7 +2978,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: pb.advance(block, body_top); if l0 == 0 { let y0 = pb.y; - (float_rects, pl) = anchor_floats(ctx, pb, p, y0, 0.0, excl_mark, &label); + (float_rects, pl, rel) = again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, p, y0, 0.0, excl_mark, &label)); + (laid_page, laid_width) = (pb.number, pb.col_w()); anchor_y = y0; n = pl.lines.len(); } @@ -3016,9 +3089,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: && let Some(resume) = pl.lines.get(l0).map(|l| l.start) { let y0 = pb.y; - let (rects, relaid) = anchor_floats(ctx, pb, p, y0, 0.0, excl_mark, &label); + let (rects, relaid, relaid_rel) = + with_page_map(ctx, &mut page_map, |ctx| again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, p, y0, 0.0, excl_mark, &label))); if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) { - (float_rects, pl) = (rects, relaid); + (float_rects, pl, rel) = (rects, relaid, relaid_rel); + (laid_page, laid_width) = (pb.number, pb.col_w()); anchor_y = y0; n = pl.lines.len(); l0 = k; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 5dac2efca..a67fe3a43 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -13,7 +13,7 @@ use wordcraft_doc::{Document, Paragraph}; use wordcraft_fonts::FaceRef; use crate::NumeralMode; -use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text}; +use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text_on}; /// How a run set in one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) /// shows its characters. Those fonts are proprietary and usually missing: a substitute has other @@ -1317,7 +1317,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { match wordcraft_doc::eq::parse_overstrike(instr) { Some(o) => overstrike(&mut b, &o, instr, start, end, &rc, &mut enclosures), None => { - let (t, page_dep) = field_text(instr, result, env.fields); + let (t, page_dep) = field_text_on(instr, result, env.fields, env.fields.page_at(start)); has_page_fields |= page_dep; b.shape_atomic(&t, start, end, &rc); } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index e767a930d..7baaad370 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5397,6 +5397,222 @@ fn automatic_spacing_drops_between_list_items() { } } +/// #1352 helpers: a body paragraph with a PAGE field after each piece of `pieces` (a piece may +/// hold a page break), after `before` paragraphs that each start a new page. +fn page_field_doc(before: usize, pieces: &[&str]) -> Document { + let mut d = Document::new(); + let mut body = Vec::new(); + for i in 0..before { + let mut q = wordcraft_doc::Paragraph::with_text(&format!("Filler {i}"), Default::default()); + q.props.page_break_before = Some(i > 0); + body.push(wordcraft_doc::para_block(q)); + } + let mut p = wordcraft_doc::Paragraph::new(); + for piece in pieces { + let n = p.len(); + p.insert_text(n, piece, &Default::default()).unwrap(); + let n = p.len(); + p.insert_object(n, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, &Default::default()) + .unwrap(); + } + p.props.page_break_before = Some(before > 0); + body.push(wordcraft_doc::para_block(p)); + d.body = body; + d +} + +/// #1352: the field results drawn on each page (only the lines placed there), and the text +/// ranges of body paragraph `block` placed on each page, in order. +fn shown_and_ranges(l: &DocLayout, block: u32) -> (Vec>, Vec>) { + let mut shown = Vec::new(); + let mut ranges = Vec::new(); + for page in &l.pages { + let mut on = Vec::new(); + for it in &page.items { + let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, .. } = it else { continue }; + if path.0 != [block] { + continue; + } + let (Some(f), Some(e)) = (para.lines.get(*l0), l1.checked_sub(1).and_then(|k| para.lines.get(k))) else { continue }; + on.extend(para.shown.iter().filter(|s| s.0 >= f.c0 && s.0 < e.c1).map(|s| s.1.clone())); + ranges.push(f.start..e.stop); + } + shown.push(on); + } + (shown, ranges) +} + +/// The placed pieces of a paragraph cover its text once, in order. +fn assert_contiguous(ranges: &[std::ops::Range], len: usize) { + let mut at = 0; + for r in ranges { + assert_eq!(r.start, at, "piece {r:?} doesn't follow on from {at}: {ranges:?}"); + at = r.end; + } + assert_eq!(at, len, "the text up to {len} is placed: {ranges:?}"); +} + +/// #1352: a PAGE field after a page break inside its paragraph shows the page it is on. +#[test] +fn page_field_after_intra_paragraph_break_shows_its_page() { + let brk = wordcraft_doc::para::PAGE_BREAK; + let d = page_field_doc(0, &["A", &format!("{brk}B")]); + let l = lay(&d); + assert_eq!(l.pages.len(), 2); + let (shown, _) = shown_and_ranges(&l, 0); + assert_eq!(shown, [vec!["1".to_string()], vec!["2".to_string()]]); +} + +/// #1352: a paragraph running over onto the next page shows that page's number in its tail. +#[test] +fn page_field_in_a_split_paragraph_tail_shows_its_page() { + let long = "words that fill the page ".repeat(400); + let d = page_field_doc(0, &["Head", &long]); + let l = lay(&d); + assert!(l.pages.len() >= 2, "{} pages", l.pages.len()); + let (shown, ranges) = shown_and_ranges(&l, 0); + assert_eq!(shown.first().unwrap(), &["1"]); + let last = l.pages.len().to_string(); + assert_eq!(shown.last().unwrap(), &[last]); + assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(0)).map(|p| p.len()).unwrap_or(0)); +} + +/// #1352: from page 9 to page 10 every field gets a digit wider; the lines on page 10 are broken +/// again for the wider results, and the paragraph's text is still placed exactly once. +#[test] +fn page_fields_from_page_nine_to_ten_rebreak_the_rest() { + let pieces: Vec = (0..900).map(|i| format!(" word{i}")).collect(); + let pieces: Vec<&str> = pieces.iter().map(String::as_str).collect(); + let d = page_field_doc(8, &pieces); + let l = lay(&d); + assert!(l.pages.len() >= 10, "{} pages", l.pages.len()); + let (shown, ranges) = shown_and_ranges(&l, 8); + let nine = shown.get(8).unwrap(); + let ten = shown.get(9).unwrap(); + assert!(!nine.is_empty() && nine.iter().all(|s| s == "9"), "page 9: {nine:?}"); + assert!(!ten.is_empty() && ten.iter().all(|s| s == "10"), "page 10: {ten:?}"); + assert_eq!(shown.iter().map(Vec::len).sum::(), 900, "every field drawn once"); + assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(8)).map(|p| p.len()).unwrap_or(0)); + // No line overflows its column with the wider results: each ends with the space after a field + // (which may hang), so the field before it ends within the line's right edge. + for (pi, page) in l.pages.iter().enumerate() { + for it in &page.items { + let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, .. } = it else { continue }; + if path.0 != [8] { + continue; + } + for line in para.lines.get(*l0..*l1).unwrap_or(&[]) { + let ink = line.xs.len().checked_sub(2).and_then(|k| line.xs.get(k)).copied().unwrap_or(0.0); + assert!(ink <= line.right + 0.5, "page {}: a line runs to {ink} past its right edge {}", pi + 1, line.right); + } + } + } +} + +/// #1352: a footnote's PAGE field, referenced from the part of a paragraph on its second page, +/// shows the page the footnote is on (the paragraph's own page map doesn't leak into it). +#[test] +fn footnote_page_field_from_a_split_paragraph_shows_its_page() { + let long = "words that fill the page ".repeat(200); + let mut d = page_field_doc(0, &["Head", &long, "Tail"]); + let end = d.end_of(StoryRef::Body); + // A custom mark, so the note marks aren't mistaken for page numbers. + let id = d.add_part( + wordcraft_doc::PartKind::Footnote, + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Note on page ", Default::default()))], + ); + d.insert_object(&end, InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: "*".into() }, &Default::default()) + .unwrap(); + let note = d.para_mut(StoryRef::Part(id), &Path::top(0)).unwrap(); + let n = note.len(); + note.insert_object(n, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, &Default::default()) + .unwrap(); + let l = lay(&d); + assert_eq!(l.pages.len(), 2, "the paragraph runs onto a second page"); + // The PAGE results drawn in `story`, by page (not the "*" note marks). + let fields_in = |story: StoryRef| -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (pi, page) in l.pages.iter().enumerate() { + for it in page.items.iter().chain(page.footer.iter()) { + let Placed::Lines { story: s, para, l0, l1, .. } = it else { continue }; + let (Some(f), Some(e)) = (para.lines.get(*l0), l1.checked_sub(1).and_then(|k| para.lines.get(k))) else { continue }; + if *s != story { + continue; + } + out.extend(para.shown.iter().filter(|x| x.0 >= f.c0 && x.0 < e.c1 && x.1 != "*").map(|x| (pi, x.1.clone()))); + } + } + out + }; + let body = fields_in(StoryRef::Body); + assert_eq!(body.first(), Some(&(0, "1".to_string())), "{body:?}"); + assert!(body.iter().all(|(pi, t)| *t == (pi + 1).to_string()), "{body:?}"); + assert_eq!(body.last().map(|x| x.0), Some(1), "{body:?}"); + let in_note = fields_in(StoryRef::Part(id)); + assert_eq!(in_note, [(1, "2".to_string())], "the note is on page 2 and says so"); +} + +/// The automatic equation numbers drawn in each body paragraph (from the layout of its last +/// piece, the one its last lines are drawn with), in order. +fn equation_numbers(l: &DocLayout) -> Vec { + let mut last: Vec<(u32, Arc)> = Vec::new(); + for it in l.pages.iter().flat_map(|p| p.items.iter()) { + if let Placed::Lines { story: StoryRef::Body, path, para, .. } = it + && let [b] = path.0[..] + { + match last.iter_mut().find(|e| e.0 == b) { + Some(e) => e.1 = para.clone(), + None => last.push((b, para.clone())), + } + } + } + last.sort_by_key(|e| e.0); + let mut out = Vec::new(); + for (_, pl) in last { + for (_, m) in &pl.maths { + let all: String = + m.items.iter().filter_map(|it| if let crate::math::MItem::Glyphs { text, .. } = it { Some(text.as_str()) } else { None }).collect(); + if let Some(at) = all.rfind('(') { + out.push(all[at..].to_string()); + } + } + } + out +} + +/// Laying a paragraph out again (placed again after a page break before, or the rest of it again +/// for a later page's PAGE fields) doesn't count its numbered equations twice: they read (1), (2), (3). +#[test] +fn relaid_paragraphs_keep_their_equation_numbers() { + let eq = |s: &str| InlineObject::Equation { + linear: s.into(), + display: true, + math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(s), ..Default::default() }, + }; + let mut d = Document::from_text("Before\n\nTail"); + // Paragraph 1: a numbered equation on a new page (laid out again there). + let p1 = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + p1.insert_object(0, eq("a=b#"), &Default::default()).unwrap(); + p1.props.page_break_before = Some(true); + // Paragraph 2: long, with a PAGE field, running onto the next page (the rest laid out again + // there), then a numbered equation. + let p2 = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap(); + let long = "words that fill the page ".repeat(400); + p2.insert_text(0, &long, &Default::default()).unwrap(); + let n = p2.len(); + p2.insert_object(n, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false }, &Default::default()) + .unwrap(); + let n = p2.len(); + p2.insert_object(n, eq("c=d#"), &Default::default()).unwrap(); + // Paragraph 3: one more. + d.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())); + let p3 = d.para_mut(StoryRef::Body, &Path::top(3)).unwrap(); + p3.insert_object(0, eq("e=f#"), &Default::default()).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 3, "{} pages", l.pages.len()); + assert_eq!(equation_numbers(&l), ["(1)", "(2)", "(3)"]); +} + /// #1116: a document of a top paragraph with `before` points of space before, filler lines, a /// paragraph of several lines (block `t`) with 6 pt after and, when `note`, a footnote on its last line. fn space_after_doc(before: f32, note: bool) -> (Document, usize, Option) { diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 556c5bd5a..e02d4908d 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-13 · **Change:** trivial (a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: table rows share their cell margins, exact rows add the bottom margin and never split, rows split across pages like Word; 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-13 · **Change:** 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, @@ -61,7 +61,7 @@ font can't match Word's metrics. | Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | | Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — | | 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) | — | +| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #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) | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 | | 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 | @@ -84,6 +84,7 @@ font can't match Word's metrics. | 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) | | 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | | 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page | +| 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | From a9afeb4bfe15c7b7dbfd64bb0fc68b132aa3af08 Mon Sep 17 00:00:00 2001 From: Saad Abdullah Date: Wed, 14 Oct 2026 22:26:10 +0500 Subject: [PATCH 060/199] fix(proof): classify each whitespace token once when checking spelling (#1335) (#1490) Co-authored-by: Saad Abdullah --- crates/proof/src/lib.rs | 80 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index f9eead19d..671a0e803 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -480,7 +480,7 @@ pub fn check_spelling(text: &str) -> Vec { /// [`check_spelling`] with the File › Options › Proofing choices. pub fn check_spelling_with(text: &str, opts: &Options) -> Vec { - words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, ProofLang::En, opts)).collect() + spelling_issues(text, |_| ProofLang::En, opts) } /// Spelling issues in a paragraph of text in `lang` (none for a language without a dictionary; @@ -489,17 +489,42 @@ pub fn check_spelling_in(text: &str, lang: ProofLang) -> Vec { if !lang.is_proofed() { return Vec::new(); } - words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, lang, &Options::default())).collect() + spelling_issues(text, |_| lang, &Options::default()) } -/// The spelling issue of the word at `a..b` of `text`, if it is misspelled in `lang`. Words in -/// web and e-mail addresses are skipped (unless `opts` says otherwise). -fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Options) -> Option { - let w = text.get(a..b)?; +/// Spelling issues of every word of `text`, each word spelled in `lang(word_start)`. +/// +/// The web/e-mail classification of the whitespace-delimited token around a word is computed +/// once per token and shared by every word punctuation splits out of it. Classifying per word +/// rescanned the token each time, so a long whitespace-free run of punctuation-separated words +/// cost quadratic scanning (#1335). +fn spelling_issues(text: &str, lang: impl Fn(usize) -> ProofLang, opts: &Options) -> Vec { + let mut token = (0, 0, false); + words(text) + .into_iter() + .filter_map(|(a, b)| { + if a < token.0 || a >= token.1 { + token = enclosing_token(text, a, b); + } + spelling_issue(text, a, b, lang(a), opts, token.2) + }) + .collect() +} + +/// The whitespace-delimited token enclosing the word at `a..b` of `text`: its byte range, and +/// whether it looks like a web or e-mail address. +fn enclosing_token(text: &str, a: usize, b: usize) -> (usize, usize, bool) { let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0); let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len()); let token = text.get(ts..te).unwrap_or(""); - let internet = token.contains("://") || token.contains('@') || token.starts_with("www."); + (ts, te, token.contains("://") || token.contains('@') || token.starts_with("www.")) +} + +/// The spelling issue of the word at `a..b` of `text`, if it is misspelled in `lang`. Words in +/// web and e-mail addresses are skipped (unless `opts` says otherwise); `internet` is the +/// classification of the word's enclosing token, from [`enclosing_token`]. +fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Options, internet: bool) -> Option { + let w = text.get(a..b)?; if (opts.ignore_internet && internet) || is_correct_in_with(w, lang, opts) { return None; } @@ -583,7 +608,7 @@ pub fn check_text_with(text: &str, spans: &[(Range, ProofLang)], opts: &O let k = merged.partition_point(|(r, _)| r.end <= i); merged.get(k).filter(|(r, _)| r.start <= i).map_or(ProofLang::En, |s| s.1) }; - let mut v: Vec = words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, lang_at(a), opts)).collect(); + let mut v: Vec = spelling_issues(text, lang_at, opts); if !grammar { v.sort_by_key(|i| (i.start, i.end)); return v; @@ -803,6 +828,45 @@ mod tests { assert_eq!(bad, vec!["sentense", "mispelled"]); } + #[test] + fn address_token_covers_every_word_split_out_of_it() { + // #1335: one whitespace-delimited token, however punctuation splits it, has one + // web/e-mail classification, shared by all its words. + let t = "zxq@mail.example.com,blorp;qwirpy wugwug"; + let bad: Vec<&str> = check_spelling(t).iter().map(|i| &t[i.start..i.end]).collect(); + assert_eq!(bad, vec!["wugwug"]); + let opts = Options { ignore_internet: false, ..Options::default() }; + let bad: Vec<&str> = check_spelling_with(t, &opts).iter().map(|i| &t[i.start..i.end]).collect(); + assert_eq!(bad.first(), Some(&"zxq"), "{bad:?}"); + assert!(bad.contains(&"qwirpy"), "{bad:?}"); + assert_eq!(bad.last(), Some(&"wugwug"), "{bad:?}"); + } + + #[test] + fn spelling_scales_over_one_long_token() { + // #1335: thousands of punctuation-separated words inside a single + // whitespace-free token: one classification for the token, one issue per + // misspelled word, through every public spelling entry point. + let word = |i: usize| { + let mut w = String::from("zxq"); + let mut n = i; + loop { + w.push((b'a' + (n % 26) as u8) as char); + n /= 26; + if n == 0 { + break; + } + } + w + }; + let t = (0..2000).map(word).collect::>().join(","); + assert_eq!(check_spelling(&t).len(), 2000); + assert_eq!(check_spelling_in(&t, ProofLang::En).len(), 2000); + let opts = Options { ignore_internet: false, ..Options::default() }; + assert_eq!(check_spelling_with(&t, &opts).len(), 2000); + assert_eq!(check_text(&t, &[]).into_iter().filter(|i| i.kind == IssueKind::Spelling).count(), 2000); + } + #[test] fn grammar_issues() { let t = "this is is a apple , and an car. Done"; From dea3e217fa8c86b03a804552faddfb37dcdea8f1 Mon Sep 17 00:00:00 2001 From: cyril265 <3495565+cyril265@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:42:34 +0200 Subject: [PATCH 061/199] Shapes: rounded rectangles keep their own corner radius (#1476) * Shapes: rounded rectangles keep their own corner radius A roundRect's adj (its corner in 100000ths of the shorter side) is read, kept on the shape, saved back, and used for the screen, the PDF and Tight/Through wrapping. Without one the corner stays at 16%. * Render: test rounded rectangle corners through layout and rendering * Docs: progress log and file-format parity for rounded rectangle corners * Render: pass the shape's corner to the caret-background hit test (#299 code from main) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/connector.rs | 31 +++++++++++- crates/doc/src/wrap.rs | 23 +++++---- crates/docx/src/read/story.rs | 14 ++++++ crates/docx/src/write/story.rs | 12 ++++- crates/docx/tests/fixtures.rs | 43 +++++++++++++++++ crates/docx/tests/roundtrip.rs | 2 +- crates/layout/src/contour.rs | 2 +- crates/layout/src/display.rs | 18 +++++-- crates/pdf/src/lib.rs | 31 +++++++----- crates/pdf/src/tests.rs | 87 +++++++++++++++++++++++++++++++--- crates/render/src/lib.rs | 81 ++++++++++++++++++++++++++----- docs/file-format-parity.md | 5 +- 13 files changed, 304 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a41cd1834..2e496d6a1 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-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | | 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both | | 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon | @@ -301,6 +302,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings | | 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 5f662a24f..2cfa5b277 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -1,5 +1,5 @@ //! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text -//! warp, and a connector's ends. +//! warp, a rounded rectangle's corner, and a connector's ends. //! //! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line //! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be @@ -34,6 +34,10 @@ pub struct ShapeExtra { pub arrow_start: bool, #[serde(skip_serializing_if = "is_false")] pub arrow_end: bool, + /// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in + /// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub corner: Option, } fn is_zero(v: &u32) -> bool { @@ -49,6 +53,19 @@ impl ShapeExtra { } } +/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round. +pub const MAX_CORNER: u32 = 50_000; + +/// A rounded rectangle's corner radius without its own: 16% of the shorter side. +pub const DEFAULT_CORNER: f32 = 0.16; + +/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see +/// [`ShapeExtra::corner`]). +pub fn corner_radius(corner: Option, w: f32, h: f32) -> f32 { + let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0); + side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0) +} + /// One glued end of a connector: the drawing id of the shape and its connection site index. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -237,6 +254,18 @@ mod tests { } } + #[test] + fn corner_radius_follows_the_shorter_side() { + let near = |a: f32, b: f32| (a - b).abs() < 1e-4; + assert!(near(corner_radius(None, 200.0, 100.0), 16.0)); + assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0)); + assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565)); + assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0)); + assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end"); + assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0)); + assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0)); + } + #[test] fn group_connectors_follow_their_shapes() { let a = ShapeExtra { id: 7, ..Default::default() }; diff --git a/crates/doc/src/wrap.rs b/crates/doc/src/wrap.rs index 5b4f5ae09..598286496 100644 --- a/crates/doc/src/wrap.rs +++ b/crates/doc/src/wrap.rs @@ -59,8 +59,8 @@ impl WrapPolygon { /// A shape's outline in unit coordinates (0..1 across a `w` × `h` frame), flattened to straight /// lines. `None` for shapes whose outline is their rectangle (rectangles, text boxes, lines, -/// freeforms). -pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option> { +/// freeforms). `corner` is a rounded rectangle's (see [`crate::connector::ShapeExtra::corner`]). +pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option) -> Option> { use std::f32::consts::{FRAC_PI_2, PI, TAU}; let pos = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { 0.0 }; let (w, h) = (pos(w), pos(h)); @@ -75,8 +75,8 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option> | ShapeKind::CurvedConnector => return None, ShapeKind::Ellipse => (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect(), ShapeKind::RoundedRectangle => { - // Corner radius as drawn: 16% of the shorter side. - let r = 0.16 * w.min(h); + // Corner radius as drawn. + let r = crate::connector::corner_radius(corner, w, h); let (rx, ry) = if w > 0.0 && h > 0.0 { (r / w, r / h) } else { (0.0, 0.0) }; let mut pts = Vec::new(); for (cx, cy, a0) in [(1.0 - rx, 1.0 - ry, 0.0), (rx, 1.0 - ry, FRAC_PI_2), (rx, ry, PI), (1.0 - rx, ry, PI + FRAC_PI_2)] { @@ -223,12 +223,19 @@ mod tests { ShapeKind::Heart, ] { for (w, h) in [(100.0, 50.0), (0.0, 0.0), (f32::NAN, 10.0)] { - let pts = shape_outline(kind, w, h).unwrap(); - assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}"); - assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}"); + for corner in [None, Some(0), Some(3_565), Some(u32::MAX)] { + let pts = shape_outline(kind, w, h, corner).unwrap(); + assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}"); + assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}"); + } } } - assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0).is_none()); + assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0, None).is_none()); + // The end of the bottom-right corner: in the frame's corner when the corner is square, + // 16% of the height (8 of 100 wide) in from it by default. + let corner_pt = |corner| shape_outline(ShapeKind::RoundedRectangle, 100.0, 50.0, corner).unwrap()[6]; + assert!(corner_pt(Some(0)).0 > 0.999 && corner_pt(Some(0)).1 > 0.999, "{:?}", corner_pt(Some(0))); + assert!((corner_pt(None).0 - 0.92).abs() < 0.001, "{:?}", corner_pt(None)); } #[test] diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 7c50ebb07..429d0fcf2 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1251,6 +1251,14 @@ impl Reader<'_> { part.body.auto_fit = bp.child("a:spAutoFit").is_some(); } let mut extra = ShapeExtra::default(); + // A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as + // rounded rectangles keep the default. + if prst == "roundRect" { + extra.corner = sppr + .and_then(|s| s.child("a:prstGeom")) + .and_then(|g| preset_adj(g, "adj")) + .and_then(|v| u32::try_from(v.clamp(0, i64::from(wordcraft_doc::connector::MAX_CORNER))).ok()); + } // Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle. let arrow = |tag: &str| ln.and_then(|l| l.child(tag)).and_then(|e| e.attr("type")).is_some_and(|t| t != "none"); extra.arrow_start = arrow("a:headEnd"); @@ -1730,6 +1738,12 @@ fn text_warp(e: &El) -> Option { Some(w) } +/// A preset geometry's adjust value `name` (`a:avLst/a:gd` with `fmla="val N"`). +fn preset_adj(geom: &El, name: &str) -> Option { + let gd = geom.child("a:avLst")?.children("a:gd").find(|g| g.attr("name") == Some(name))?; + gd.attr("fmla")?.trim().strip_prefix("val")?.trim().parse::().ok() +} + fn effect_list(l: &El) -> ShapeEffects { let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0); let shadow = l.child("a:outerShdw").map(|e| { diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 1c01e4a5a..07e31d4ec 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -624,7 +624,7 @@ impl Writer<'_> { } // Tight and Through wrapping need a polygon: the shape's outline unless it has one. if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() { - float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh) + float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner) .and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts)) .map(std::sync::Arc::new); } @@ -965,7 +965,15 @@ impl Writer<'_> { Some(f) => cust_geom(w, f, *sw, *sh), None => { w.open("a:prstGeom", &[("prst", prst)]); - w.empty("a:avLst", &[]); + match extra.corner.filter(|_| *kind == ShapeKind::RoundedRectangle) { + Some(c) => { + w.open("a:avLst", &[]); + let fmla = format!("val {}", c.min(wordcraft_doc::connector::MAX_CORNER)); + w.empty("a:gd", &[("name", "adj"), ("fmla", &fmla)]); + w.close("a:avLst"); + } + None => w.empty("a:avLst", &[]), + } w.close("a:prstGeom"); } } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 350a007b0..ebf9585e0 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1520,6 +1520,49 @@ fn theme_styled_connectors_and_callout_boxes_keep_their_paint() { assert!(matches!(¶s(&again)[0].objects[0], InlineObject::Shape { stroke, extra, .. } if *stroke == Some(theme[7]) && extra.arrow_end)); } +/// A rounded rectangle's corner is its `adj` (100000ths of the shorter side), read, capped and +/// written back; without one, and for presets only drawn as rounded rectangles, the default. +#[test] +fn rounded_rectangle_corners_are_read_and_saved() { + let geom = |prst: &str, av: &str| { + format!(r#"{av}"#) + }; + let gd = |fmla: &str| format!(r#""#); + let body = [ + geom("roundRect", &gd("val 3565")), + geom("roundRect", ""), + geom("roundRect", &gd("val 90000")), + geom("roundRect", &gd("val -20")), + geom("roundRect", &gd("*/ 1 2 3")), + geom("roundRect", r#""#), + geom("wedgeRoundRectCallout", &gd("val 3565")), + ] + .iter() + .map(|sppr| anchored_wsp("Rounded", 2540000, 1270000, sppr, "", "")) + .collect::(); + let corners = |d: &Document| -> Vec<(ShapeKind, Option)> { + paras(d)[0] + .objects + .iter() + .filter_map(|o| match o { + InlineObject::Shape { kind, extra, .. } => Some((*kind, extra.corner)), + _ => None, + }) + .collect() + }; + let rounded = ShapeKind::RoundedRectangle; + let expected = + vec![(rounded, Some(3565)), (rounded, None), (rounded, Some(50_000)), (rounded, Some(0)), (rounded, None), (rounded, None), (rounded, None)]; + let d = read_body(&format!("{body}")); + assert_eq!(corners(&d), expected); + let bytes = wordcraft_docx::write(&d).unwrap(); + let mut xml = String::new(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + assert!(xml.contains(r#""#), "{xml}"); + assert_eq!(corners(&wordcraft_docx::read(&bytes).unwrap()), expected); +} + /// #611: legacy VML lines and rectangles (documents without DrawingML). #[test] fn vml_lines_and_rectangles() { diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 791cf203f..f5ab84fab 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1926,7 +1926,7 @@ fn wrap_polygons_round_trip() { let got = paras(&r)[0]; let floats: Vec = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect(); assert_eq!(floats[0], edited); - let ellipse = WrapPolygon::from_unit(false, &shape_outline(ShapeKind::Ellipse, 80.0, 40.0).unwrap()); + let ellipse = WrapPolygon::from_unit(false, &shape_outline(ShapeKind::Ellipse, 80.0, 40.0, None).unwrap()); assert_eq!(floats[1].wrap, Wrap::Through); assert_eq!(floats[1].wrap_polygon.as_deref(), ellipse.as_ref()); } diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index a306ca159..1c245b885 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -145,7 +145,7 @@ pub fn wrap_outline(o: &InlineObject, picture: impl FnOnce(&str, [f32; 4]) -> Op return Some(Arc::new(p.unit())); } match o { - InlineObject::Shape { kind, .. } => shape_outline(*kind, w, h).map(Arc::new), + InlineObject::Shape { kind, extra, .. } => shape_outline(*kind, w, h, extra.corner).map(Arc::new), InlineObject::Image { media, crop, .. } => picture(media, *crop), _ => None, } diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 1f2ca0043..aa799bfda 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -68,6 +68,8 @@ pub enum Draw { stroke_width: f32, /// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]). effects: wordcraft_doc::effects::ShapeEffects, + /// A rounded rectangle's corner ([`wordcraft_doc::connector::ShapeExtra::corner`]). + corner: Option, }, /// An ink stroke: a line through `pts` (page points) with round ends and joins, `width` /// wide, in `color` at opacity `alpha`. @@ -1006,6 +1008,7 @@ fn lines( stroke: Some(text_color(&st.rc.color, None)), stroke_width: e.stroke, effects: Default::default(), + corner: None, }); } // Ruby (Phonetic Guide) text over its base text, in runs of one style. @@ -1216,6 +1219,7 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec { let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy)); @@ -1293,7 +1297,7 @@ fn shape_draws( return; } let Some(f) = freeform.filter(|_| kind == ShapeKind::Freeform) else { - out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects }); + out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner: extra.corner }); return; }; if f.is_ink() && opts.hide_ink { @@ -1347,7 +1351,7 @@ fn line_draws( .collect(); out.push(Draw::Path { segs, fill: None, stroke, stroke_width }); } - None => out.push(Draw::Shape { rect, kind, fill: None, stroke, stroke_width, effects }), + None => out.push(Draw::Shape { rect, kind, fill: None, stroke, stroke_width, effects, corner: None }), } let Some(color) = stroke else { return }; // Our line runs from the bottom-left corner to the top-right; connectors from the top-left @@ -1523,7 +1527,15 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f /// A Drawing Canvas's background and outline over its frame `rect`. fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw { - Draw::Shape { rect, kind: ShapeKind::Rectangle, fill: c.fill, stroke: c.stroke, stroke_width: c.stroke_width, effects: Default::default() } + Draw::Shape { + rect, + kind: ShapeKind::Rectangle, + fill: c.fill, + stroke: c.stroke, + stroke_width: c.stroke_width, + effects: Default::default(), + corner: None, + } } /// Whether `c` stands upright in top-to-bottom East Asian text (`tbRl`) rather than turning with diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e9d135a01..1a818f3c4 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -371,18 +371,27 @@ fn grown(r: &Rect, g: f32, dx: f32, dy: f32) -> Rect { /// A shape's shadow and glow, behind it: bands of its silhouette (the same approximation of a /// blur as the raster renderer, see [`wordcraft_doc::effects::bands`]). -fn shape_effects(s: &mut Surface, kind: ShapeKind, rect: &Rect, fx: &wordcraft_doc::effects::ShapeEffects, filled: bool, stroked: bool, sw: f32) { +fn shape_effects( + s: &mut Surface, + kind: ShapeKind, + corner: Option, + rect: &Rect, + fx: &wordcraft_doc::effects::ShapeEffects, + filled: bool, + stroked: bool, + sw: f32, +) { let sw = if sw.is_finite() { sw.clamp(0.25, 200.0) } else { 0.75 }; let silhouette = |s: &mut Surface, c: Rgb, a: f32, g: f32, dx: f32, dy: f32| { if filled { let grow = g + if stroked { sw / 2.0 } else { 0.0 }; - if let Some(p) = shape_path(kind, &grown(rect, grow, dx, dy)) { + if let Some(p) = shape_path(kind, &grown(rect, grow, dx, dy), corner) { s.set_stroke(None); s.set_fill(Some(fill(c, a))); s.draw_path(&p); } } else if sw + 2.0 * g > 0.05 - && let Some(p) = shape_path(kind, &grown(rect, 0.0, dx, dy)) + && let Some(p) = shape_path(kind, &grown(rect, 0.0, dx, dy), corner) { s.set_fill(None); s.set_stroke(Some(Stroke { paint: rgb::Color::new(c.0, c.1, c.2).into(), width: sw + 2.0 * g, opacity: norm(a), ..Default::default() })); @@ -404,9 +413,9 @@ fn shape_effects(s: &mut Surface, kind: ShapeKind, rect: &Rect, fx: &wordcraft_d s.set_stroke(None); } -/// Outline of a basic shape in a rectangle (same geometry as the raster renderer). -/// A line or connector may be flat (zero width or height: horizontal or vertical), not both. -fn shape_path(kind: ShapeKind, r: &Rect) -> Option { +/// Outline of a basic shape in a rectangle (same geometry as the raster renderer); `corner` is a +/// rounded rectangle's ([`wordcraft_doc::connector::ShapeExtra::corner`]). +fn shape_path(kind: ShapeKind, r: &Rect, corner: Option) -> Option { let flat_ok = kind.is_open() && r.w.max(r.h) > 0.0 && r.w.min(r.h) >= 0.0; if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || !(flat_ok || r.w > 0.0 && r.h > 0.0) { return None; @@ -437,7 +446,7 @@ fn shape_path(kind: ShapeKind, r: &Rect) -> Option { // A freeform is drawn from its own paths; without them, its frame. ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => poly(&mut pb, &[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]), ShapeKind::RoundedRectangle => { - let rad = r.w.min(r.h) * 0.16; + let rad = wordcraft_doc::connector::corner_radius(corner, r.w, r.h); let k = rad * (1.0 - K); pb.move_to(x0 + rad, y0); pb.line_to(x1 - rad, y0); @@ -841,19 +850,19 @@ impl Exporter<'_> { s.draw_path(&p); s.set_stroke(None); } - Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects } => { - let Some(p) = shape_path(*kind, rect) else { return }; + Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects, corner } => { + let Some(p) = shape_path(*kind, rect, *corner) else { return }; let can_fill = *kind != ShapeKind::Line; let fx = effects.sanitized(); if !fx.is_empty() && (f.is_some() && can_fill || stroke.is_some()) { - shape_effects(s, *kind, rect, &fx, f.is_some() && can_fill, stroke.is_some(), *stroke_width); + shape_effects(s, *kind, *corner, rect, &fx, f.is_some() && can_fill, stroke.is_some(), *stroke_width); } // Soft edges: the fill fades out toward the outline (which fades with it). if let (Some(rad), Some(c), true) = (fx.soft_edge, f, can_fill) { let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0); s.set_stroke(None); for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) { - if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0)) { + if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0), *corner) { s.set_fill(Some(fill(*c, a))); s.draw_path(&p); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index ebf4a10c0..94d928ab8 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -429,20 +429,20 @@ fn shapes_have_paths() { ShapeKind::Diamond, ShapeKind::Triangle, ] { - assert!(shape_path(k, &r).is_some(), "{k:?}"); + assert!(shape_path(k, &r, None).is_some(), "{k:?}"); } // A line or connector may be flat (horizontal or vertical); a closed shape may not. for k in [ShapeKind::Line, ShapeKind::StraightConnector] { - assert!(shape_path(k, &Rect::new(0.0, 5.0, 10.0, 0.0)).is_some(), "{k:?} horizontal"); - assert!(shape_path(k, &Rect::new(5.0, 0.0, 0.0, 10.0)).is_some(), "{k:?} vertical"); - assert!(shape_path(k, &Rect::new(5.0, 5.0, 0.0, 0.0)).is_none(), "{k:?} without length"); + assert!(shape_path(k, &Rect::new(0.0, 5.0, 10.0, 0.0), None).is_some(), "{k:?} horizontal"); + assert!(shape_path(k, &Rect::new(5.0, 0.0, 0.0, 10.0), None).is_some(), "{k:?} vertical"); + assert!(shape_path(k, &Rect::new(5.0, 5.0, 0.0, 0.0), None).is_none(), "{k:?} without length"); // Hostile sizes reach `shape_path` unclamped through the public fields. for (w, h) in [(f32::NAN, 0.0), (f32::INFINITY, 0.0), (10.0, f32::NEG_INFINITY), (-10.0, 0.0), (0.0, -10.0), (10.0, -1.0)] { - assert!(shape_path(k, &Rect { x: 0.0, y: 0.0, w, h }).is_none(), "{k:?} {w} x {h}"); + assert!(shape_path(k, &Rect { x: 0.0, y: 0.0, w, h }, None).is_none(), "{k:?} {w} x {h}"); } } - assert!(shape_path(ShapeKind::Rectangle, &Rect::new(0.0, 5.0, 10.0, 0.0)).is_none()); - assert!(shape_path(ShapeKind::Ellipse, &Rect::new(0.0, 0.0, f32::NAN, 1.0)).is_none()); + assert!(shape_path(ShapeKind::Rectangle, &Rect::new(0.0, 5.0, 10.0, 0.0), None).is_none()); + assert!(shape_path(ShapeKind::Ellipse, &Rect::new(0.0, 0.0, f32::NAN, 1.0), None).is_none()); assert_eq!(parse_iso("2024-02-03T04:05:06Z"), Some((2024, 2, 3, 4, 5, 6))); assert_eq!(parse_iso("garbage"), None); assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0)); @@ -544,6 +544,79 @@ fn count_glyphs_and_paths(bytes: &[u8]) -> (usize, usize) { (n.0, n.1) } +/// The filled paths drawn, over every page, in page space. +fn filled_paths(bytes: &[u8]) -> Vec { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + use hayro_syntax::Pdf; + struct Fills(Vec); + impl Device<'_> for Fills { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, _: &Paint<'_>, mode: &PathDrawMode) { + if matches!(mode, PathDrawMode::Fill(_)) { + self.0.push(transform * path.clone()); + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = Pdf::new(bytes.to_vec()).expect("parse"); + let cache = InterpreterCache::new(); + let mut fills = Fills(Vec::new()); + for page in pdf.pages().iter() { + let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut fills); + } + fills.0 +} + +#[test] +fn rounded_rectangle_corners_follow_their_radius() { + // `roundRect` with its own `adj`: Word's address boxes use small corners (adj 3565: 3.6% of + // the shorter side), which were drawn at 16%. + use kurbo::Shape; + use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; + let doc = |corner: Option| { + let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 200.0, ..Default::default() }; + let shape = InlineObject::Shape { + kind: ShapeKind::RoundedRectangle, + w: 200.0, + h: 100.0, + fill: Some(wordcraft_doc::props::Rgb(0xD9, 0xD9, 0xD9)), + stroke: None, + stroke_width: 0.0, + float, + story: None, + freeform: None, + effects: Default::default(), + extra: wordcraft_doc::connector::ShapeExtra { corner, ..Default::default() }, + }; + let mut p = Paragraph::with_text("Anchor", CharProps::default()); + p.insert_object(0, shape, &CharProps::default()).unwrap(); + let mut d = Document::new(); + d.body = vec![para_block(p)]; + d + }; + // Whether the shape covers the point `inset` points in from a corner of its frame. + let covers = |corner: Option, inset: f64| { + let fills = filled_paths(&export(&doc(corner), &PdfOptions { tagged: false, ..Default::default() }).unwrap()); + let shape = fills.iter().find(|p| (p.bounding_box().width() - 200.0).abs() < 0.5).expect("the shape is filled"); + let b = shape.bounding_box(); + shape.contains((b.x0 + inset, b.y0 + inset).into()) + }; + assert!(covers(Some(0), 1.0), "square corner"); + assert!(!covers(Some(3_565), 1.0) && covers(Some(3_565), 4.0), "3.565 pt corner"); + assert!(!covers(None, 4.0), "16 pt corner by default"); +} + #[test] fn unsubsettable_font_is_drawn_as_outlines() { let db = wordcraft_fonts::FontDb::global(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b17601799..8092d632a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -455,10 +455,10 @@ fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOpti Draw::Fill { rect, color, alpha } if rect.contains(p) => (*color, *alpha), // The shape's own outline (an ellipse's corners are not inside it). Shape fills are // drawn opaque (the model has no fill transparency), so they cover what's beneath. - Draw::Shape { rect, kind, fill: Some(fill), .. } + Draw::Shape { rect, kind, fill: Some(fill), corner, .. } if *kind != ShapeKind::Line && rect.contains(p) && { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); - kurbo::Shape::contains(&shape_path(*kind, r), kurbo::Point::new(p.x as f64, p.y as f64)) + kurbo::Shape::contains(&shape_path(*kind, r, *corner), kurbo::Point::new(p.x as f64, p.y as f64)) } => { (*fill, 1.0) @@ -801,9 +801,9 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); ctx.stroke_path(&path); } - Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => { + Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); - let path = shape_path(*kind, r); + let path = shape_path(*kind, r, *corner); ctx.set_transform(view); let fx = effects.sanitized(); let can_fill = *kind != ShapeKind::Line && fill.is_some(); @@ -816,11 +816,11 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl let grow = g + if stroke.is_some() { sw / 2.0 } else { 0.0 }; let rr = r.inflate(grow, grow) + d; if rr.width() > 0.0 && rr.height() > 0.0 { - ctx.fill_path(&shape_path(*kind, rr)); + ctx.fill_path(&shape_path(*kind, rr, *corner)); } } else if sw + 2.0 * g > 0.05 { ctx.set_stroke(kurbo::Stroke::new(sw + 2.0 * g)); - ctx.stroke_path(&shape_path(*kind, r + d)); + ctx.stroke_path(&shape_path(*kind, r + d, *corner)); } }; if let Some(sh) = fx.shadow { @@ -844,7 +844,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl let rr = r.inflate(g as f64, g as f64); if rr.width() > 0.0 && rr.height() > 0.0 { ctx.set_paint(color(opts.ink(*f), a)); - ctx.fill_path(&shape_path(*kind, rr)); + ctx.fill_path(&shape_path(*kind, rr, *corner)); } } return; @@ -911,8 +911,9 @@ fn ink_path(pts: &[(f32, f32)]) -> Option { Some(p) } -/// Outline of a basic shape in a rectangle. -pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath { +/// Outline of a basic shape in a rectangle; `corner` is a rounded rectangle's +/// ([`wordcraft_doc::connector::ShapeExtra::corner`]). +pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option) -> BezPath { let (cx, cy) = (r.center().x, r.center().y); let poly = |pts: &[(f64, f64)]| { let mut p = BezPath::new(); @@ -929,7 +930,10 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath { match kind { // A freeform is drawn from its own paths; without them, its frame. ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => r.to_path(0.1), - ShapeKind::RoundedRectangle => kurbo::RoundedRect::from_rect(r, r.width().min(r.height()) * 0.16).to_path(0.1), + ShapeKind::RoundedRectangle => { + let radius = wordcraft_doc::connector::corner_radius(corner, r.width() as f32, r.height() as f32); + kurbo::RoundedRect::from_rect(r, f64::from(radius)).to_path(0.1) + } ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1), ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]), ShapeKind::Diamond => poly(&[(cx, r.y0), (r.x1, cy), (cx, r.y1), (r.x0, cy)]), @@ -1057,6 +1061,7 @@ mod tests { stroke: None, stroke_width: 0.0, effects: Default::default(), + corner: None, }; let at = |x: f32, y: f32| { let mut bg = [255.0; 3]; @@ -1381,15 +1386,67 @@ mod tests { fn shapes_have_paths() { let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0); for k in [ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, ShapeKind::Line] { - assert!(!shape_path(k, r).elements().is_empty()); + assert!(!shape_path(k, r, None).elements().is_empty()); } } + /// A rounded rectangle laid out and rendered: its corner pixel is painted when the corner is + /// square, left white with the default corner; turned a quarter, the same. + #[test] + fn rendered_rounded_rectangles_follow_their_corner() { + use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; + let rendered = |corner: Option, rot: f32| { + let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, rot, ..Default::default() }; + let shape = wordcraft_doc::para::InlineObject::Shape { + kind: ShapeKind::RoundedRectangle, + w: 200.0, + h: 200.0, + fill: Some(wordcraft_doc::props::Rgb(0, 0, 0)), + stroke: None, + stroke_width: 0.0, + float, + story: None, + freeform: None, + effects: Default::default(), + extra: wordcraft_doc::connector::ShapeExtra { corner, ..Default::default() }, + }; + let mut p = wordcraft_doc::Paragraph::new(); + p.insert_object(0, shape, &Default::default()).unwrap(); + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()) + }; + for rot in [0.0, 90.0] { + // 3 pt in from the frame's top-left corner, and its middle. + let (corner, middle) = ((103, 103), (200, 200)); + let square = rendered(Some(0), rot); + assert_eq!(square.pixel(corner.0, corner.1)[0], 0, "square corner, turned {rot}"); + let rounded = rendered(None, rot); + assert_eq!(rounded.pixel(corner.0, corner.1)[0], 255, "default corner, turned {rot}"); + assert_eq!(rounded.pixel(middle.0, middle.1)[0], 0, "filled, turned {rot}"); + } + } + + /// A rounded rectangle's corner is its file's (`roundRect`'s `adj`), not always 16%. + #[test] + fn rounded_rectangle_corners_follow_their_radius() { + use kurbo::Shape; + let r = kurbo::Rect::new(0.0, 0.0, 200.0, 100.0); + let inside = |corner, pt: (f64, f64)| shape_path(ShapeKind::RoundedRectangle, r, corner).contains(pt.into()); + // Two points inside the top-left corner: 1 pt and 4 pt in from both edges. + let (tip, near) = ((1.0, 1.0), (4.0, 4.0)); + assert!(inside(Some(0), tip), "square corner"); + assert!(!inside(Some(3_565), tip) && inside(Some(3_565), near), "3.565 pt corner"); + assert!(!inside(None, near), "16 pt corner by default"); + assert!(!inside(Some(u32::MAX), (14.0, 14.0)) && inside(Some(u32::MAX), (100.0, 1.0)), "round ends"); + } + /// #611: a line runs as DrawingML's `line` preset does, top-left to bottom-right (Word's /// files flip it for the other diagonal), not bottom-left to top-right. #[test] fn a_line_runs_from_top_left_to_bottom_right() { - let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0)); + let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0), None); assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]); } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 49c6777fc..95456e6b5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** 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-13 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; 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; @@ -61,7 +61,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped | -| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | +| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Not read: vertical text (`layout-flow`) | @@ -163,6 +163,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Rounded rectangles keep their own corner radius: `roundRect` `adj` read, written back and drawn (#1476) | | 2026-10-12 | trivial | VML text boxes keep their internal margins (`inset`), fit to their text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`); hidden drawings (`visibility:hidden`, `wp:docPr/@hidden`) are kept but not shown | | 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | From 1be06ca323a9f9937f28f48518be737c6941745c Mon Sep 17 00:00:00 2001 From: cyril265 <3495565+cyril265@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:42:53 +0200 Subject: [PATCH 062/199] Fields: PAGEREF in headers and footers shows its bookmark's page (#1477) * Fields: PAGEREF in headers and footers shows its bookmark's page Headers and footers are laid out once the body is paginated, so a page reference there now shows the number of the page its bookmark starts on, in that page's number format, instead of the result stored in the file. Like the page totals in headers and footers, it is filled in after pagination and doesn't make the body lay out again. Mail-merged letters use this for "Page X of Y" per letter (Y = PAGEREF to a bookmark at the letter's end). Page references in the body keep their result until fields are updated. * Contributors: add cyril265 --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- contributors/people.toml | 2 + crates/layout/src/fields.rs | 58 ++++++++- crates/layout/src/lib.rs | 87 ++++++++++++- crates/layout/src/tests_pages.rs | 205 +++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +- 6 files changed, 354 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e496d6a1..54af7f651 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-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: progress log: PAGEREF in headers and footers, #1477; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | +| 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | | 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | | 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both | | 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon | @@ -303,6 +304,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | +| 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | | 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings | | 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | diff --git a/contributors/people.toml b/contributors/people.toml index 43ac40f0b..3b6156d16 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -75,6 +75,8 @@ sync_github_name = false real_name = "Manuel Carmona" display_name = "manucarcor" [people.odedigo] +[people.cyril265] +display_name = "cyril265" [people.webdevtodayjason] display_name = "Jason Brashear" diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index 3c71a836b..8799e12f4 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -22,12 +22,20 @@ pub struct FieldCtx { pub title: Arc, pub author: Arc, pub filename: Arc, + /// Where the body's bookmarks start, for `PAGEREF` in headers and footers: known once the + /// body is paginated, empty while it is. + pub bookmark_pages: Arc, + /// A key for caches standing for `bookmark_pages` (0 when it's empty). + pub bookmarks_key: u64, /// Where a paragraph runs onto later pages: from text offset `.0` on, its fields are on page /// `.1` (ascending offsets). Empty except during the layout of a body paragraph split across /// pages, for its later pages (#1352); nothing else laid out meanwhile sees it. pub page_from: Vec<(usize, u32)>, } +/// Bookmark name → the number and number format of the page it starts on. +pub type BookmarkPages = HashMap; + /// A footnote's or endnote's number, as its section's options give it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct NoteNum { @@ -52,8 +60,8 @@ impl FieldCtx { self.page_from.iter().rev().find(|(from, _)| *from <= off).map_or(self.page, |(_, page)| *page) } /// A key for caches: the parts of the context a page-dependent paragraph depends on. - pub fn page_key(&self) -> (u32, u32, u32, u32) { - (self.page, self.pages, self.section_pages, self.section) + pub fn page_key(&self) -> (u32, u32, u32, u32, u64) { + (self.page, self.pages, self.section_pages, self.section, self.bookmarks_key) } } @@ -110,6 +118,10 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S "NUMPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.pages), true), "SECTIONPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section_pages), true), "SECTION" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section), true), + "PAGEREF" => match pageref_page(instr, ctx) { + Some((n, page_format)) => (fmt.unwrap_or(page_format).format(n), true), + None => (result.to_string(), false), + }, "TITLE" if result.is_empty() => (ctx.title.to_string(), false), "AUTHOR" if result.is_empty() => (ctx.author.to_string(), false), "FILENAME" if result.is_empty() => (ctx.filename.to_string(), false), @@ -117,6 +129,26 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S } } +/// The page a `PAGEREF` field's bookmark starts on, when known (see +/// [`FieldCtx::bookmark_pages`]) and the field shows its number (`\p` shows "above" or "below"). +fn pageref_page(instr: &str, ctx: &FieldCtx) -> Option<(u32, NumFormat)> { + // The bookmark is the word after the field name, up to a switch written right after it. + let word = instr.split_whitespace().nth(1)?; + let target = word.split('\\').next().unwrap_or("").trim_matches('"'); + if target.is_empty() || has_switch(instr, 'p') { + return None; + } + ctx.bookmark_pages.get(target).copied() +} + +/// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`). +fn has_switch(instr: &str, sw: char) -> bool { + instr.split('\\').skip(1).any(|s| { + let mut chars = s.chars(); + chars.next().is_some_and(|c| c.eq_ignore_ascii_case(&sw)) && chars.next().is_none_or(|c| !c.is_alphanumeric()) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -134,4 +166,26 @@ mod tests { assert_eq!(field_code_text(" PAGE \\* ROMAN "), "{ PAGE \\* ROMAN }"); assert_eq!(field_code_text(""), "{ }"); } + + #[test] + fn page_references_show_their_bookmarks_page() { + let pages = HashMap::from([("End_1".to_string(), (3, NumFormat::Decimal)), ("Intro".to_string(), (4, NumFormat::LowerRoman))]); + let ctx = FieldCtx { bookmark_pages: Arc::new(pages), bookmarks_key: 1, ..Default::default() }; + assert_eq!(field_text("PAGEREF End_1 \\* Arabic \\* MERGEFORMAT", "9", &ctx), ("3".into(), true)); + assert_eq!(field_text(" PAGEREF End_1 \\h ", "9", &ctx).0, "3"); + assert_eq!(field_text("PAGEREF End_1\\h", "9", &ctx).0, "3"); + // The page's own format, unless a switch says otherwise. + assert_eq!(field_text("PAGEREF Intro", "9", &ctx).0, "iv"); + assert_eq!(field_text("PAGEREF Intro \\* Arabic", "9", &ctx).0, "4"); + assert_eq!(field_text("PAGEREF \"End_1\"", "9", &ctx).0, "3"); + // Unknown bookmarks, "above"/"below" (\p) and a context without pages keep the result. + assert_eq!(field_text("PAGEREF Missing", "9", &ctx), ("9".into(), false)); + for instr in ["PAGEREF End_1 \\p", "PAGEREF End_1 \\P", "PAGEREF End_1 \\p\\h", "PAGEREF End_1\\h\\p"] { + assert_eq!(field_text(instr, "above", &ctx), ("above".into(), false), "{instr}"); + } + assert_eq!(field_text("PAGEREF End_1 \\* MERGEFORMAT", "9", &ctx).0, "3", "\\* isn't \\p"); + assert_eq!(field_text("PAGEREF \\h", "9", &ctx), ("9".into(), false)); + assert_eq!(field_text("PAGEREF", "9", &ctx), ("9".into(), false)); + assert_eq!(field_text("PAGEREF End_1", "9", &FieldCtx::default()), ("9".into(), false)); + } } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 053210ec1..07010614f 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -461,7 +461,7 @@ struct Key { rev: u64, width: u32, label: Option, - page: Option<(u32, u32, u32, u32)>, + page: Option<(u32, u32, u32, u32, u64)>, /// The pages a split paragraph's fields are on after its first (`FieldCtx::page_from`). page_from: u64, table: u64, @@ -537,7 +537,7 @@ fn hyphenation_key(doc: &Document) -> (bool, u32, bool, u32) { fn has_page_fields(p: &Paragraph) -> bool { p.objects.iter().any(|o| match o { InlineObject::Field { instr, .. } => { - matches!(fields::field_name(instr).as_str(), "PAGE" | "NUMPAGES" | "SECTIONPAGES" | "SECTION") + matches!(fields::field_name(instr).as_str(), "PAGE" | "NUMPAGES" | "SECTIONPAGES" | "SECTION" | "PAGEREF") } _ => false, }) @@ -2064,6 +2064,13 @@ fn layout_pass( } if !reflow { ctx.track_totals = false; + // Page references in headers and footers are filled in after pagination, like their page + // totals: they don't make the body lay out again. + if headers_footers_refer_to_pages(doc) { + let marks = bookmark_pages(doc, &pages, §ions); + ctx.fields.bookmarks_key = marks_key(&marks); + ctx.fields.bookmark_pages = Arc::new(marks); + } headers_footers(&mut ctx, &mut pages, §ions); if doc.settings.mirror_margins { mirror_even_pages(&mut pages, §ions); @@ -3440,6 +3447,82 @@ fn linked(sections: &[(usize, &SectionProps)], si: usize, get: fn(&SectionProps) set } +/// Whether a header, a footer or a text box shows a `PAGEREF` field, which they resolve from the +/// paginated body (see [`bookmark_pages`]). +fn headers_footers_refer_to_pages(doc: &Document) -> bool { + use wordcraft_doc::PartKind; + doc.parts.iter().filter(|(_, part)| matches!(part.kind, PartKind::Header | PartKind::Footer | PartKind::TextBox)).any(|(id, _)| { + let story = StoryRef::Part(*id); + doc.para_paths(story) + .iter() + .filter_map(|path| doc.para(story, path)) + .any(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, .. } if fields::field_name(instr) == "PAGEREF"))) + }) +} + +/// Where each bookmark in the body starts: the number and number format of the page it is placed +/// on (with two of the same name, the first in the document). +fn bookmark_pages(doc: &Document, pages: &[Page], sections: &[(usize, &SectionProps)]) -> fields::BookmarkPages { + // Each paragraph's bookmarks: name, offset, and its line in the paragraph layout last looked + // up (the pieces of a paragraph usually share one). + let mut wanted: HashMap)>> = HashMap::new(); + let mut seen = HashSet::new(); + for path in doc.para_paths(StoryRef::Body) { + let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; + if !p.objects.iter().any(|o| matches!(o, InlineObject::BookmarkStart { .. })) { + continue; + } + for (off, o) in p.object_offsets().into_iter().zip(&p.objects) { + if let InlineObject::BookmarkStart { name } = o + && seen.insert(name.as_str()) + { + wanted.entry(path.clone()).or_default().push((name.clone(), off, None)); + } + } + } + let mut out = fields::BookmarkPages::new(); + for page in pages { + if wanted.is_empty() { + break; + } + let format = sections.get(page.section).map(|(_, s)| s.page_num_format).unwrap_or_default(); + for it in &page.items { + let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, .. } = it else { continue }; + let Some(marks) = wanted.get_mut(path) else { continue }; + let layout = Arc::as_ptr(para) as usize; + marks.retain_mut(|(name, off, line)| { + let li = match *line { + Some((k, li)) if k == layout => li, + _ => { + let li = para.line_of(*off); + *line = Some((layout, li)); + li + } + }; + if !(*l0..*l1).contains(&li) { + return true; + } + out.insert(std::mem::take(name), (page.number, format)); + false + }); + if marks.is_empty() { + wanted.remove(path); + } + } + } + out +} + +/// A key for caches standing for `marks` (0 for none). +fn marks_key(marks: &fields::BookmarkPages) -> u64 { + if marks.is_empty() { + return 0; + } + let mut v: Vec<_> = marks.iter().collect(); + v.sort_by(|a, b| a.0.cmp(b.0)); + hash_of(&v) +} + fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)]) { let total = pages.len() as u32; // Pages per section. diff --git a/crates/layout/src/tests_pages.rs b/crates/layout/src/tests_pages.rs index 15b69e206..d69d69d64 100644 --- a/crates/layout/src/tests_pages.rs +++ b/crates/layout/src/tests_pages.rs @@ -154,3 +154,208 @@ fn caret_in_a_continued_footnote_is_found_on_its_page() { assert_eq!(para.line_of(up.off), k, "hint {hint}: {up:?}"); } } + +/// A letter per section, each restarting its page numbers, with "Page X of Y" in its footer +/// where Y is a `PAGEREF` to a bookmark at the letter's end (how mail-merged letters count their +/// own pages). The footer shows each letter's last page, not the file's stale result. +#[test] +fn page_references_in_footers_show_their_bookmarks_page() { + let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, code: false }; + let footer = |d: &mut Document, mark: &str| { + let mut p = Paragraph::with_text("Page of ", Default::default()); + p.insert_object(5, field("PAGE"), &Default::default()).unwrap(); + let n = p.len(); + p.insert_object(n, field(&format!("PAGEREF {mark} \\* Arabic \\* MERGEFORMAT")), &Default::default()).unwrap(); + d.add_part(PartKind::Footer, vec![para_block(p)]) + }; + let mut d = Document::from_text("Letter A 1\nLetter A 2\nLetter A 3\nLetter B 1\nLetter B 2"); + let (fa, fb) = (footer(&mut d, "End_A"), footer(&mut d, "End_B")); + for i in [1, 2, 4] { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(true); + } + for (i, mark) in [(2, "End_A"), (4, "End_B")] { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + let n = p.len(); + p.insert_object(n, InlineObject::BookmarkStart { name: mark.into() }, &Default::default()).unwrap(); + } + let restart = |id| SectionProps { + page_num_start: Some(1), + footers: wordcraft_doc::section::HeaderSet { default: Some(id), ..Default::default() }, + ..d.last_section.clone() + }; + let (a, b) = (restart(fa), restart(fb)); + d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().section = Some(Box::new(a)); + d.last_section = b; + let shown = |l: &DocLayout| -> Vec { + l.pages + .iter() + .map(|p| { + p.footer + .iter() + .flat_map(|it| if let Placed::Lines { para, .. } = it { para.shown.iter().map(|s| s.1.clone()).collect() } else { vec![] }) + .collect::>() + .join("/") + }) + .collect() + }; + let mut cache = LayoutCache::new(); + let l = layout(&d, &mut cache, &LayoutOptions::default()); + assert_eq!(shown(&l), ["1/3", "2/3", "3/3", "1/2", "2/2"]); + // The bookmark moves to the letter's first page: only the reference changes, and the + // footers laid out before (same page, same totals) aren't reused. + let last = d.para_mut(StoryRef::Body, &Path::top(4)).unwrap(); + let n = last.len(); + last.delete(n - '\u{FFFC}'.len_utf8(), n).unwrap(); + let first = d.para_mut(StoryRef::Body, &Path::top(3)).unwrap(); + let n = first.len(); + first.insert_object(n, InlineObject::BookmarkStart { name: "End_B".into() }, &Default::default()).unwrap(); + assert_eq!(shown(&layout(&d, &mut cache, &LayoutOptions::default())), ["1/3", "2/3", "3/3", "1/1", "2/1"]); + // In the body, a page reference keeps its result until fields are updated. + let mut body = Document::from_text("Intro\nSee page "); + let p = body.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.insert_object(0, InlineObject::BookmarkStart { name: "Intro".into() }, &Default::default()).unwrap(); + let p = body.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + let n = p.len(); + p.insert_object(n, field("PAGEREF Intro"), &Default::default()).unwrap(); + // The footer's reference to the same bookmark is resolved. + let mut fp = Paragraph::new(); + fp.insert_object(0, field("PAGEREF Intro"), &Default::default()).unwrap(); + let id = body.add_part(PartKind::Footer, vec![para_block(fp)]); + body.last_section.footers.default = Some(id); + let l = lay(&body); + assert_eq!(footer_shown(&l), ["1"]); + let texts: Vec = l.pages[0] + .items + .iter() + .flat_map(|it| if let Placed::Lines { para, .. } = it { para.shown.iter().map(|s| s.1.clone()).collect() } else { vec![] }) + .collect(); + assert_eq!(texts, ["9"]); +} + +/// The fields' text in each page's footer, joined with "/". +fn footer_shown(l: &DocLayout) -> Vec { + l.pages + .iter() + .map(|p| { + p.footer + .iter() + .flat_map(|it| if let Placed::Lines { para, .. } = it { para.shown.iter().map(|s| s.1.clone()).collect() } else { vec![] }) + .collect::>() + .join("/") + }) + .collect() +} + +/// A footer of `PAGEREF` fields to `marks` (stored result "9"), set on the last section. +fn page_ref_footer(d: &mut Document, marks: &[&str]) -> u32 { + let mut p = Paragraph::new(); + for mark in marks { + let n = p.len(); + let field = InlineObject::Field { instr: format!("PAGEREF {mark}"), result: "9".into(), locked: false, code: false }; + p.insert_object(n, field, &Default::default()).unwrap(); + } + let id = d.add_part(PartKind::Footer, vec![para_block(p)]); + d.last_section.footers.default = Some(id); + id +} + +fn mark(d: &mut Document, path: &Path, off: usize, name: &str) { + d.para_mut(StoryRef::Body, path).unwrap().insert_object(off, InlineObject::BookmarkStart { name: name.into() }, &Default::default()).unwrap(); +} + +/// Bookmarks in the second half of a paragraph split over two pages, in a table cell and in a +/// section numbered in roman numerals are found on the page and in the format they land in. +#[test] +fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections() { + // One long paragraph over two pages, marked at its start and its end. + let mut d = Document::from_text(&"Long paragraph text running on. ".repeat(160)); + let n = d.para(StoryRef::Body, &Path::top(0)).unwrap().len(); + mark(&mut d, &Path::top(0), n, "End"); + mark(&mut d, &Path::top(0), 0, "Start"); + page_ref_footer(&mut d, &["Start", "End"]); + let l = lay(&d); + assert_eq!(l.pages.len(), 2, "the paragraph runs over two pages"); + assert_eq!(footer_shown(&l), ["1/2", "1/2"]); + + // A table cell on the second page. + let mut d = Document::from_text("First\nSecond"); + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true); + d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(wordcraft_doc::Table::new(1, 1, 300.0))).unwrap(); + let cell = Path(vec![2, 0, 0, 0]); + d.para_mut(StoryRef::Body, &cell).unwrap().insert_text(0, "In a cell", &Default::default()).unwrap(); + mark(&mut d, &cell, 0, "Cell"); + page_ref_footer(&mut d, &["Cell"]); + assert_eq!(footer_shown(&lay(&d)), ["2", "2"]); + + // The second section numbers its pages in lower-case roman numerals. + let mut d = Document::from_text("Arabic\nRoman 1\nRoman 2"); + d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.page_break_before = Some(true); + let n = d.para(StoryRef::Body, &Path::top(2)).unwrap().len(); + mark(&mut d, &Path::top(2), n, "End"); + let first = d.last_section.clone(); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first)); + d.last_section.page_num_start = Some(1); + d.last_section.page_num_format = wordcraft_doc::section::NumFormat::LowerRoman; + page_ref_footer(&mut d, &["End"]); + assert_eq!(footer_shown(&lay(&d)), ["", "ii", "ii"], "the first section has no footer of its own"); + + // Two bookmarks of the same name: the first in the document counts. + let mut d = Document::from_text("First\nSecond"); + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true); + mark(&mut d, &Path::top(1), 0, "Twice"); + mark(&mut d, &Path::top(0), 0, "Twice"); + page_ref_footer(&mut d, &["Twice"]); + assert_eq!(footer_shown(&lay(&d)), ["1", "1"]); +} + +/// A text box in a footer resolves its page references; one in the body keeps its result. +#[test] +fn page_references_in_footer_text_boxes_are_resolved() { + use wordcraft_doc::para::{Float, ShapeKind, Wrap}; + let text_box = |d: &mut Document| { + let mut p = Paragraph::new(); + let field = InlineObject::Field { instr: "PAGEREF End".into(), result: "9".into(), locked: false, code: false }; + p.insert_object(0, field, &Default::default()).unwrap(); + let story = d.add_part(PartKind::TextBox, vec![para_block(p)]); + InlineObject::Shape { + kind: ShapeKind::TextBox, + w: 100.0, + h: 40.0, + fill: None, + stroke: None, + stroke_width: 0.0, + float: Float { wrap: Wrap::Inline, ..Default::default() }, + story: Some(story), + freeform: None, + effects: Default::default(), + extra: Default::default(), + } + }; + let box_text = |l: &DocLayout, story: Option| -> Vec { + l.pages + .iter() + .flat_map(|p| p.items.iter().chain(&p.footer)) + .flat_map(|it| match it { + Placed::Lines { story: StoryRef::Part(id), para, .. } if Some(*id) == story => para.shown.iter().map(|s| s.1.clone()).collect(), + _ => vec![], + }) + .collect() + }; + let mut d = Document::from_text("First\nSecond"); + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true); + let n = d.para(StoryRef::Body, &Path::top(1)).unwrap().len(); + mark(&mut d, &Path::top(1), n, "End"); + let in_footer = text_box(&mut d); + let footer_box = in_footer.text_box(); + let mut fp = Paragraph::with_text("Footer ", Default::default()); + let n = fp.len(); + fp.insert_object(n, in_footer, &Default::default()).unwrap(); + let id = d.add_part(PartKind::Footer, vec![para_block(fp)]); + d.last_section.footers.default = Some(id); + let in_body = text_box(&mut d); + let body_box = in_body.text_box(); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(0, in_body, &Default::default()).unwrap(); + let l = lay(&d); + assert!(box_text(&l, footer_box).iter().all(|t| t == "2") && !box_text(&l, footer_box).is_empty(), "{:?}", box_text(&l, footer_box)); + assert_eq!(box_text(&l, body_box), ["9"]); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index e02d4908d..c21628949 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-13 · **Change:** 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-13 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; 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, @@ -61,7 +61,7 @@ font can't match Word's metrics. | Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | | Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — | | 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); 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) | — | +| 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 | — | | Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 | | 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 | @@ -79,6 +79,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | PAGEREF in headers and footers shows its bookmark's page (#1477) | | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | | 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) | From ff4e560e7de22ca5916892a5c03ac104b4b7f3fc Mon Sep 17 00:00:00 2001 From: Arthur M Date: Wed, 14 Oct 2026 19:43:06 +0200 Subject: [PATCH 063/199] Ctrl+A stays on this page, and in a table grows cell, then row, then table, then the document (#1424) (#1478) * Ctrl+A stays on this page, and in a table grows cell, then row, then table, then the document (#1424) Fixes #1424 * Table: Option::map for the outer-cell selection (clippy::manual_map on Rust 1.99) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/caret.rs | 14 ++++++- crates/engine/src/cmd/table.rs | 34 +++++++++++++++ crates/engine/src/cmd/tools.rs | 3 +- crates/engine/src/session.rs | 6 +++ crates/engine/src/tests.rs | 76 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 7 +++- crates/ui-egui/src/chrome.rs | 6 ++- crates/ui-egui/src/lib.rs | 18 +++++++- 8 files changed, 158 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index 950e59917..50c1cd019 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -277,9 +277,21 @@ fn set(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -fn select_all(s: &mut Session, _: &Value) -> CmdResult { +fn select_all(s: &mut Session, v: &Value) -> CmdResult { + if s.selects_whole_story() { + s.column = None; + s.column_mode = false; + return sel_result(s); + } + if !p::bool(v, "whole").unwrap_or(false) + && let Some(r) = super::table::grow_select_all(s) + { + return r; + } let st = s.sel.focus.story; s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) }; + s.column = None; + s.column_mode = false; sel_result(s) } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e1e09f7fb..3c153d860 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -747,6 +747,40 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"result": text})) } +/// Ctrl+A in a table, as in Word: the cell, then its row, then the table. A nested table that +/// is already fully selected grows to the cell around it. Returns `None` when the next step is +/// the whole story (not in a table, or the top-level table is already selected). +pub(crate) fn grow_select_all(s: &mut Session) -> Option { + let story = s.sel.focus.story; + if let Some(cr) = s.cell_range().filter(|cr| cr.story == story).cloned() { + let tp = cr.table.clone(); + let (last_row, last_col, one_cell) = { + let t = s.doc.table(story, &tp)?; + let last_row = t.rows.len().saturating_sub(1); + let last_col = t.cols().saturating_sub(1); + let one_cell = cr.rows.0 == cr.rows.1 + && t.rows.get(cr.rows.0).is_some_and(|row| (0..row.cells.len()).any(|c| grid_cols(t, cr.rows.0, c) == cr.cols)); + (last_row, last_col, one_cell) + }; + if cr.rows == (0, last_row) && cr.cols == (0, last_col) { + return tp.cell().map(|(otp, or, oc)| select_cells(s, &otp, Some((or, Some(oc))))); + } + if cr.rows.0 == cr.rows.1 && cr.cols == (0, last_col) { + return Some(select_cells(s, &tp, None)); + } + if one_cell { + return Some(select_cells(s, &tp, Some((cr.rows.0, None)))); + } + return Some(select_cells(s, &tp, None)); + } + let (a, b) = s.sel.ordered(); + let (tp, r, c) = a.path.cell()?; + if b.path.cell() != Some((tp.clone(), r, c)) { + return None; + } + Some(select_cells(s, &tp, Some((r, Some(c))))) +} + fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option)>) -> CmdResult { let story = s.sel.focus.story; let Some(t) = s.doc.table(story, tp) else { return sel_result(s) }; diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 8dcceda07..669601433 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -647,7 +647,8 @@ pub fn specs() -> Vec { } else { "select.all" }; - s.run(id, &json!({})) + let params = if id == "select.all" { json!({"whole": true}) } else { json!({}) }; + s.run(id, ¶ms) }) .key("F8") .pure(), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 9b9d21d86..f15030e9e 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -691,6 +691,12 @@ impl Session { self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) } + pub fn selects_whole_story(&self) -> bool { + let st = self.sel.focus.story; + let (a, b) = self.sel.ordered(); + a == self.doc.start_of(st) && b == self.doc.end_of(st) + } + /// Document revision (bumped by every change). pub fn rev(&self) -> u64 { self.rev diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 476d2bc9c..5b6a81304 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1127,6 +1127,82 @@ fn tables_commands() { assert!(s.run("table.merge", &json!({})).is_err()); } +fn cells(s: &Session) -> Option<((usize, usize), (usize, usize))> { + s.cell_range().map(|x| (x.rows, x.cols)) +} + +#[test] +fn select_all_grows_cell_then_row_then_table_then_document() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Before"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "A1"})); + assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((0, 0))); + + run(&mut s, "select.all", json!({})); + assert_eq!(cells(&s), Some(((0, 0), (0, 0))), "first Ctrl+A selects the cell"); + assert_eq!(s.selected_text(), "A1"); + + run(&mut s, "select.all", json!({})); + assert_eq!(cells(&s), Some(((0, 0), (0, 1))), "second Ctrl+A selects the row"); + + run(&mut s, "select.all", json!({})); + assert_eq!(cells(&s), Some(((0, 1), (0, 1))), "third Ctrl+A selects the table"); + + run(&mut s, "select.all", json!({})); + assert!(s.column.is_none()); + let (a, b) = s.sel.ordered(); + assert_eq!(a, s.doc.start_of(StoryRef::Body)); + assert_eq!(b, s.doc.end_of(StoryRef::Body)); + assert!(s.selected_text().starts_with("Before")); + + run(&mut s, "select.all", json!({})); + let (a, b) = s.sel.ordered(); + assert_eq!((a, b), (s.doc.start_of(StoryRef::Body), s.doc.end_of(StoryRef::Body))); +} + +#[test] +fn select_all_from_a_column_grows_to_the_table() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "table.selectColumn", json!({})); + assert_eq!(cells(&s), Some(((0, 1), (0, 0)))); + run(&mut s, "select.all", json!({})); + assert_eq!(cells(&s), Some(((0, 1), (0, 1))), "Ctrl+A on a column selects the table"); +} + +#[test] +fn select_all_keeps_the_whole_story_when_it_ends_in_a_table() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "A1"})); + s.doc.body.pop(); + run(&mut s, "select.all", json!({})); + run(&mut s, "select.all", json!({})); + run(&mut s, "select.all", json!({})); + run(&mut s, "select.all", json!({})); + let whole = (s.doc.start_of(StoryRef::Body), s.doc.end_of(StoryRef::Body)); + assert!(s.doc.end_of(StoryRef::Body).path.cell().is_some()); + assert_eq!(s.sel.ordered(), whole); + run(&mut s, "select.all", json!({})); + assert!(s.column.is_none()); + assert_eq!(s.sel.ordered(), whole); +} + +#[test] +fn select_extend_from_a_table_paragraph_selects_the_document() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "Hello world"})); + run(&mut s, "select.paragraph", json!({})); + run(&mut s, "select.extend", json!({})); + assert!(s.column.is_none()); + let (a, b) = s.sel.ordered(); + assert_eq!(a, s.doc.start_of(StoryRef::Body)); + assert_eq!(b, s.doc.end_of(StoryRef::Body)); +} + /// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a /// long sentence widens (wrapping within the page), and the widths are written to the grid. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1047c7ef2..9ec3c8856 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -799,7 +799,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let caret = layout.caret_on(&app.session.sel_ends().focus, app.session.page_hint); // Editing a header/footer (or a note) dims the body; once per frame, for every page. let dim_body = dims_body(app, &layout); - if let Some(c) = caret { + if let Some(c) = caret + && !app.session.selects_whole_story() + { app.session.page_hint = c.page; } let mut scroll_target: Option = None; @@ -1941,7 +1943,8 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo "paragraph" => "select.paragraph", _ => "select.line", }; - let _ = app.run(id, json!({})); + let params = if id == "select.all" { json!({"whole": true}) } else { json!({}) }; + let _ = app.run(id, params); let (start, end) = app.session.sel.ordered(); (unit, start, end) }; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 7e7483a9e..187fa7d54 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -367,7 +367,11 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing = vec2(14.0, 0.0); let l = app.session.layout(); - let page = l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1); + let page = if app.session.selects_whole_story() { + app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1 + } else { + l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1) + }; let st = |ui: &mut Ui, s: &str| { let font = TypeRung::Caption.regular(); let track = TypeRung::Caption.tracking(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 687a0b479..cddfaebbc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -944,7 +944,9 @@ impl WordApp { self.canvas.read_first_visible = None; } // Drawing leaves the caret where it was: following it would pull the view off the ink. - if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") && !id.starts_with("draw.") { + if self.session.selects_whole_story() || id == "select.all" { + self.canvas.scroll_to_caret = false; + } else if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") && !id.starts_with("draw.") { self.canvas.scroll_to_caret = true; } for req in std::mem::take(&mut self.session.ui_requests) { @@ -4997,6 +4999,20 @@ mod tests { assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view"); } + #[test] + fn select_all_does_not_scroll_to_the_end() { + let mut a = typed(); + a.canvas.scroll_to_caret = true; + a.run("select.all", json!({})).unwrap(); + assert!(!a.canvas.scroll_to_caret); + a.run("format.bold", json!({})).unwrap(); + assert!(!a.canvas.scroll_to_caret); + a.run("edit.copy", json!({})).unwrap(); + assert!(!a.canvas.scroll_to_caret); + a.run("text.insert", json!({"text": "x"})).unwrap(); + assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view"); + } + /// #1129: with no document open (the Start screen's empty window), `document.inspect` and /// `ui.render` on the control channel say so instead of reading the hidden placeholder. #[test] From 93175f979ff814c1ea9979a6282bbbc3df08231c Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:43:24 +0100 Subject: [PATCH 064/199] Word 365 cover pages: page-relative positions and freeforms drawn back to their start (#743) (#1487) - A position wrapped in mc:AlternateContent inside wp:anchor (wp14:pctPosVOffset) takes the Fallback's wp:posOffset; the shapes were placed at the top of the column. - A custGeom path that ends where it started without a:close is closed, so it's filled as in Word; the cover's panel (and its white title) was invisible. - Credits: add benandez to contributors/people.toml. Co-Authored-By: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- contributors/people.toml | 5 +++++ crates/docx/src/read/freeform.rs | 5 ++++- crates/docx/src/read/story.rs | 8 +++++++- crates/docx/tests/fixtures.rs | 22 ++++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- 7 files changed, 47 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 54af7f651..8d207e157 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-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: progress log: PAGEREF in headers and footers, #1477; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: Word 365 cover pages: positions and closed freeforms, #743; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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. @@ -170,6 +170,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged | | 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon | | 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` | +| 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show | | 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | | 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents | | 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) | @@ -349,6 +350,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: AutoCorrect replacement list follows the text's language (#835) | | 2026-10-11 | trivial | Progress log: German interface (#574) | | 2026-10-12 | trivial | Progress log: language-aware sorting and table numbers (#836, #837) | +| 2026-10-13 | trivial | Progress log: Word 365 cover pages, positions and closed freeforms (#743) | | 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) | | 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) | | 2026-10-11 | trivial | Progress log: Animation effects setting | diff --git a/contributors/people.toml b/contributors/people.toml index 3b6156d16..cc51ccaaa 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -82,4 +82,9 @@ display_name = "cyril265" display_name = "Jason Brashear" sync_github_name = false +[people.benandez] +real_name = "Martin Ball" +display_name = "Martin Ball" +sync_github_name = false + [people.Nghiathan13] diff --git a/crates/docx/src/read/freeform.rs b/crates/docx/src/read/freeform.rs index 6d2525f4d..95b224acc 100644 --- a/crates/docx/src/read/freeform.rs +++ b/crates/docx/src/read/freeform.rs @@ -65,7 +65,10 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option { } } if !cur.is_empty() { - done.push((cur, false)); + // A path drawn back to where it started is closed, though it has no `a:close` (#743: + // Word's cover pages), so it's filled. + let back = cur.len() > 2 && cur.first() == cur.last(); + done.push((cur, back)); } for (pts, closed) in done.into_iter().take(MAX_PATHS.saturating_sub(paths.len())) { paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none }); diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 429d0fcf2..392c93959 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1548,7 +1548,13 @@ fn nv_alt(p: Option<&El>) -> (String, AltText) { fn anchor_float(c: &El) -> Float { let mut f = Float { wrap: Wrap::Square, ..Float::default() }; let behind = on_off_attr(c, "behindDoc"); - for k in c.els() { + // Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in + // `mc:AlternateContent`; its Fallback gives the same position as an offset (#743). + let kids = c.els().flat_map(|k| match k.name.as_str() { + "mc:AlternateContent" => k.child("mc:Fallback").map(|f| f.els().collect()).unwrap_or_default(), + _ => vec![k], + }); + for k in kids { match k.name.as_str() { "wp:wrapSquare" => { f.wrap = Wrap::Square; diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index ebf9585e0..9f6587be6 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1734,6 +1734,28 @@ fn part_text_box(d: &Document) -> String { story.and_then(|id| d.parts.get(&id)).map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).unwrap_or_default() } +/// #743: Word's cover pages place their shapes relative to the page's size, wrapped in +/// `mc:AlternateContent` with an offset as the Fallback, and draw their freeforms back to the +/// start without `a:close`. Both shapes are read: placed at the offset, and filled. +#[test] +fn cover_page_relative_position_and_freeform_drawn_back_to_its_start() { + let geom = r#""#; + let pct = r#"4500480695"#; + let shape = anchored_wsp("Freeform 1", 5486400, 5334000, geom, "", "") + .replace(r#"25400"#, pct); + let check = |d: &Document| { + let objs = ¶s(d)[0].objects; + let InlineObject::Shape { kind, float, freeform: Some(f), .. } = &objs[0] else { panic!("{:?}", objs[0]) }; + assert_eq!(*kind, ShapeKind::Freeform); + assert_eq!((float.v_rel, float.v_align, float.y), (Anchor::Page, None, 480695.0 / 12700.0), "the Fallback's offset"); + let closed: Vec = f.paths.iter().map(|p| p.closed).collect(); + assert_eq!(closed, [true, false], "a path back at its start is closed and filled; an open line isn't"); + }; + let d = read_body(&format!("{shape}")); + check(&d); + check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap()); +} + #[test] fn embedded_fonts_survive_a_round_trip() { let table = r#""#; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 95456e6b5..c178dd7b6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; 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-13 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; previously trivial: cover-page positions in `mc:AlternateContent` and freeforms drawn back to their start, #743; 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; @@ -63,7 +63,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped | | DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | -| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | +| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Not read: vertical text (`layout-flow`) | | Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | | Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | @@ -193,6 +193,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-11 | trivial | Files with an extension WordCraft doesn't know open by content: text (`.m3u`/`.m3u8` playlists, logs…) through the encoding-detecting text import, misnamed Word/RTF files by signature, binary files refused with an error (#569) | | 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) | | 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) | +| 2026-10-13 | trivial | Word 365 cover page (#743): a position wrapped in `mc:AlternateContent` (`wp14:pctPosVOffset`) takes the Fallback's offset; a freeform path drawn back to its start is closed | 2026-10-12 | trivial | Word 365 cover page (#743): text-box internal margins and `a:spAutoFit` read and written; freeforms inside groups keep their custom geometry | | 2026-10-11 | trivial | DOCX diagrams (#611): shapes and connectors styled by the theme (`wps:style`) show their outline and fill; preset/system colours; VML lines and rectangles; lines run top-left to bottom-right as in DrawingML | | 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) | diff --git a/docs/gaps.md b/docs/gaps.md index a85a8f0ee..0e95cfbce 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-13 · **Change:** 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-13 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; 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. @@ -15,7 +15,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | # | Gap | B | Evidence | Impact | Hours | Doc | |---|---|---|---|---|---|---| -| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | +| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-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) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (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 (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | 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 ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | @@ -87,6 +87,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | | 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells (into or out of a table, whole rows); removed from the UI/UX gaps | | 2026-10-11 | trivial | Tables: selection areas, insert buttons, move handle and double-click AutoFit on the page landed; ordinary drags over cells of several rows (a run of text, not a rectangle) added to the UI/UX gaps | +| 2026-10-13 | trivial | #1: Word 365 cover page (#743): shapes placed relative to the page (`wp14:pctPosVOffset`) take their offset; freeforms drawn back to their start are filled | | 2026-10-12 | trivial | #1: Word 365 cover page (#743): freeforms in groups keep their outline; text boxes keep their own margins and Resize shape to fit text, so no line is cut | | 2026-10-12 | trivial | #3: 32-bit Windows no longer exits at launch; DirectX 12 graphics pipelines fail to create, so that build starts on OpenGL | | 2026-10-12 | trivial | Gap #3: browsing the Font list no longer keeps every previewed family in memory (#848); each applied family still loads once | From 4cf13cd1787fe685ecf8c0c5d0cb50479603f761 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:43:43 +0200 Subject: [PATCH 065/199] Fix Replace All bypassing content-control locks (#1488) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/controls.rs | 81 +++++++++++++++++++++++++------ crates/engine/src/cmd/edit.rs | 1 + docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 5 files changed, 75 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8d207e157..bdd617698 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-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: Word 365 cover pages: positions and closed freeforms, #743; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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. @@ -138,6 +138,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm |---|---| | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | +| 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion | | 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | | 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both | | 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon | @@ -306,6 +307,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm |---|---|---| | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | +| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | | 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings | | 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index e9f532045..374a27f5e 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -317,28 +317,34 @@ pub fn refuses_paragraph(s: &Session) -> bool { /// Before a deletion of `a..b`: refuse deleting a control that can't be deleted, or editing /// content that can't be edited. pub fn check_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<(), CmdError> { - if a.story != b.story { + check_deletions(s, a.story, std::iter::once((a, b))) +} + +/// Check a batch before editing, reading the story's control ranges only once. An empty +/// control list needs no per-match work, including for large Replace All operations. +pub fn check_deletions<'a>(s: &mut Session, story: StoryRef, deletions: impl IntoIterator) -> Result<(), CmdError> { + let controls = s.doc.control_ranges(story); + if controls.is_empty() { return Ok(()); } let w = OBJ.len_utf8(); - let mut refusal = None; - for r in s.doc.control_ranges(a.story) { - let whole = (&a.path, a.off) <= (&r.start.path, r.start.off) && (&r.end.path, r.end.off + w) <= (&b.path, b.off); - let cs = r.content_start(); - let touches = (&a.path, a.off) < (&r.end.path, r.end.off) && (&b.path, b.off) > (&cs.path, cs.off); - if whole && !r.control.lock.can_delete() { - refusal = Some(CANT_DELETE); - break; + for (a, b) in deletions { + if a.story != story || b.story != story { + continue; } - if !whole && touches && !r.control.lock.can_edit() { - refusal = Some(CANT_EDIT); - break; + for r in &controls { + let whole = (&a.path, a.off) <= (&r.start.path, r.start.off) && (&r.end.path, r.end.off + w) <= (&b.path, b.off); + let cs = r.content_start(); + let touches = (&a.path, a.off) < (&r.end.path, r.end.off) && (&b.path, b.off) > (&cs.path, cs.off); + if whole && !r.control.lock.can_delete() { + return Err(locked(s, CANT_DELETE)); + } + if !whole && touches && !r.control.lock.can_edit() { + return Err(locked(s, CANT_EDIT)); + } } } - match refusal { - Some(m) => Err(locked(s, m)), - None => Ok(()), - } + Ok(()) } /// After the user deleted something: a control left empty shows its placeholder again. @@ -801,6 +807,49 @@ mod editing_tests { assert!(s.doc.control_ranges(StoryRef::Body).is_empty()); } + fn replacement_control() -> Session { + let mut s = Session::new(wordcraft_doc::Document::from_text("cat cat")); + run(&mut s, "edit.find", json!({"text": "cat"})); + run(&mut s, "developer.richText", json!({"tag": "protected"})); + assert_eq!(text(&s), "cat cat"); + s + } + + #[test] + fn replace_all_refuses_locked_content_without_partial_edits() { + for track_changes in [false, true] { + let mut s = replacement_control(); + run(&mut s, "developer.properties", json!({"control": "protected", "lockEdit": true})); + s.doc.settings.track_changes = track_changes; + let before = s.doc.clone(); + let selection = s.sel.clone(); + let undo_depth = s.undo_depth(); + // The later, unlocked match would be replaced before the locked one. + assert!(s.run("edit.replaceAll", &json!({"text": "cat", "with": "fox"})).is_err()); + assert!(s.status.contains("can't be edited")); + assert_eq!(s.doc, before); + assert_eq!(s.sel, selection); + assert_eq!(s.undo_depth(), undo_depth); + } + } + + #[test] + fn replace_all_respects_control_delete_locks_but_allows_content_edits() { + let mut s = replacement_control(); + run(&mut s, "developer.properties", json!({"control": "protected", "lockDelete": true})); + let before = s.doc.clone(); + // Matching both control markers would delete the control itself. + assert!(s.run("edit.replaceAll", &json!({"text": "^gcat^g", "with": "fox"})).is_err()); + assert!(s.status.contains("can't be deleted")); + assert_eq!(s.doc, before); + // A deletion lock still permits editing the text inside the control. + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "fox"}))["replaced"], 2); + assert_eq!(text(&s), "fox fox"); + assert_eq!(s.doc.control_ranges(StoryRef::Body).len(), 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); + } + #[test] fn lists_dates_and_blocks() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a61b0a9fe..b5e742227 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -634,6 +634,7 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult { let story = s.sel.focus.story; let results = search_full(s, story, true)?; let n = results.len(); + super::controls::check_deletions(s, story, results.iter().map(|f| (&f.a, &f.b)))?; for Found { a, b, rep } in results.into_iter().rev() { let with = rep.unwrap_or_default(); if s.doc.settings.track_changes { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c178dd7b6..d1b245728 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; previously trivial: cover-page positions in `mc:AlternateContent` and freeforms drawn back to their start, #743; 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-13 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; 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; @@ -56,7 +56,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | 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. RTF/ODT/PDF export the result (moved-away text hidden like deletions) | | 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) | -| 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 | +| 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 | | Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | @@ -164,6 +164,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | Rounded rectangles keep their own corner radius: `roundRect` `adj` read, written back and drawn (#1476) | +| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | | 2026-10-12 | trivial | VML text boxes keep their internal margins (`inset`), fit to their text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`); hidden drawings (`visibility:hidden`, `wp:docPr/@hidden`) are kept but not shown | | 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | diff --git a/docs/gaps.md b/docs/gaps.md index 0e95cfbce..0afe4d636 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-13 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; 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-13 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; 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. @@ -19,7 +19,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 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) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (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 (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | 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 ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-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. | B | 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; 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 | 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**: 54 modal dialogs vs Word's ~100 | 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) | | 8 | **Proofing covers English, Polish, Portuguese and Italian only**; no dictionaries, grammar or hyphenation for the other ~46 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12) and Italian (it-IT via the installed Hunspell dictionary), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish, Portuguese and Italian | 20–40 | [localization-parity.md](localization-parity.md) | @@ -80,6 +80,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word | | 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | | 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | From 1f2b2287b6ad5a84e1853205957f98ff463f1741 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:44:02 +0200 Subject: [PATCH 066/199] RTF import: keep section breaks and their page settings (#1378) (#1495) * #1378: WIP * Co-Authored-By: Claude Opus 5.5 * #1378: pasted sections link their headers; section orientation wins over the document's Co-Authored-By: Claude Opus 5.5 * #1378: Text from File clears inserted sections' headers too Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/insert.rs | 3 +- crates/engine/src/cmd/paste.rs | 16 ++ crates/engine/src/tests.rs | 58 ++++++ crates/engine/tests/rtf_sections.rs | 67 ++++++ crates/formats/src/model.rs | 5 + crates/formats/src/rtf.rs | 309 +++++++++++++++++++++++++--- docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 9 files changed, 437 insertions(+), 35 deletions(-) create mode 100644 crates/engine/tests/rtf_sections.rs diff --git a/ROADMAP.md b/ROADMAP.md index bdd617698..473ca1107 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-13 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; 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-14 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: progress log: RTF import keeps sections, #1378; 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. @@ -164,6 +164,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) | | 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height | | 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again | +| 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -325,6 +326,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) | | 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) | +| 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index a0982ebda..e6f32e61d 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -115,7 +115,8 @@ pub fn specs() -> Vec { CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| { let path = p::req_str(v, "path")?; let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; - let frag = doc.body_fragment(); + let mut frag = doc.body_fragment(); + super::paste::unlink_sections(&mut frag.blocks); let at = delete_selection(s)?; let end = s.doc.insert_fragment(&at, &frag)?; s.sel = Selection::caret(end); diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index 7b86234ab..b95f6cb00 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -304,6 +304,21 @@ pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Para } } +/// Section breaks brought in from another document keep their page, but their header and +/// footer ids are that document's: clear them (and the different first page that went with +/// them) so the sections link to the previous section's headers and footers, as in Word. +pub(crate) fn unlink_sections(blocks: &mut [Block]) { + for b in blocks { + each_para(b, 0, &mut |p| { + if let Some(sec) = p.section.as_deref_mut() { + sec.headers = Default::default(); + sec.footers = Default::default(); + sec.title_page = false; + } + }); + } +} + /// Turn an imported document into a fragment of the session's document: bring over the media, /// lists and styles it refers to (renumbered so they can't collide with ours). fn adopt(s: &mut Session, src: Document) -> Fragment { @@ -354,6 +369,7 @@ fn adopt(s: &mut Session, src: Document) -> Fragment { } }); } + unlink_sections(&mut blocks); // Text box stories the imported shapes show; pasting gives each a new story of its own. let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect(); Fragment { blocks, parts } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5b6a81304..3fd3c1269 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -911,6 +911,64 @@ fn paste_special_lists_formats_and_pastes_text_without_formatting() { assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err()); } +#[test] +fn pasted_rtf_sections_link_their_headers_to_the_previous() { + // #1378: the RTF's own header part ids mean nothing in this document; a pasted section + // break keeps its page but links its headers and footers to the previous section. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "x"})); + let note = s.doc.add_part(wordcraft_doc::PartKind::Footnote, Vec::new()); + let _comment = s.doc.add_part(wordcraft_doc::PartKind::Comment, Vec::new()); + run(&mut s, "caret.docEnd", json!({})); + let rtf = r"{\rtf1\ansi\sectd\lndscpsxn\titlepg{\header Head\par}{\footer Foot\par}{\headerf First\par} a\par\sect\sectd{\header Two\par} b\par}"; + run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": rtf})); + let secs = s.doc.sections(); + assert_eq!(secs.len(), 2, "the pasted section break is kept"); + assert!(secs[0].1.landscape, "with its page settings"); + for (_, sp) in &secs { + for (set, kind) in [(sp.headers, wordcraft_doc::PartKind::Header), (sp.footers, wordcraft_doc::PartKind::Footer)] { + for id in [set.default, set.first, set.even].into_iter().flatten() { + assert_eq!(s.doc.parts.get(&id).map(|p| p.kind), Some(kind), "section refers to part {id} (footnote is {note})"); + } + } + } + assert_unlinked(&s); +} + +/// Every section break in the body has no headers, footers or first-page setting of its own. +fn assert_unlinked(s: &Session) { + for (i, sp) in s.doc.sections() { + assert_eq!((sp.headers, sp.footers, sp.title_page), Default::default(), "section ending at block {i}"); + } +} + +#[test] +fn text_from_file_unlinks_inserted_sections_headers() { + // #1378: Insert › Text from File brings the file's section breaks with their pages, but + // the file's header and footer ids are its own: they link to the previous section's. + let dir = std::env::temp_dir().join(format!("wordcraft-text-from-file-sections-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let rtf = r"{\rtf1\ansi\sectd\lndscpsxn\titlepg{\header Head\par}{\footer Foot\par}{\headerf First\par} a\par\sect\sectd{\header Two\par} b\par}"; + let rtf_path = dir.join("sections.rtf"); + std::fs::write(&rtf_path, rtf).unwrap(); + // The same document as DOCX. + let docx_path = dir.join("sections.docx"); + let src = crate::io::open_bytes("sections.rtf", rtf.as_bytes()).unwrap(); + std::fs::write(&docx_path, crate::io::save_bytes("sections.docx", &src).unwrap()).unwrap(); + for path in [&rtf_path, &docx_path] { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "x"})); + s.doc.add_part(wordcraft_doc::PartKind::Footnote, Vec::new()); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.textFromFile", json!({"path": path.to_str().unwrap()})); + let secs = s.doc.sections(); + assert!(secs.len() >= 2, "{path:?}: the inserted section break is kept"); + assert!(secs[0].1.landscape, "{path:?}: with its page settings"); + assert_unlinked(&s); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn paste_special_html_and_rtf_keep_bold() { let mut s = s(); diff --git a/crates/engine/tests/rtf_sections.rs b/crates/engine/tests/rtf_sections.rs new file mode 100644 index 000000000..8e4b7f853 --- /dev/null +++ b/crates/engine/tests/rtf_sections.rs @@ -0,0 +1,67 @@ +//! #1378: an RTF file with several sections converts to DOCX with one `w:sectPr` per section. +//! The RTF is original and synthetic, laid out the way word processors write sections: document +//! page defaults, then `\sectd` + section words + the section's header/footer groups, a `\sect` +//! after the last `\par` (or after a table), and a `\sectd` inside a footer's field result. +#![cfg(not(target_arch = "wasm32"))] + +use std::io::Read; + +use wordcraft_engine::io; + +const RTF: &str = r"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Times New Roman;}} +\paperw11906\paperh16838\margl1134\margr1134\margt1417\margb1134 +\sectd\headery708\footery708\colsx708 +{\headerr \pard\plain Part one - upright\par} +{\footerr \pard\plain Pg. {\field{\*\fldinst PAGE}{\fldrslt 1}}\sectd\colsx708 {\par}} +\pard\plain Annual summary\par +\pard\plain Opening text of the first part.\par \sect } +\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\pgnrestart\headery708\footery708\colsx708 +{\headerr \pard\plain Part two - wide\par} +{\footerr \pard\plain Pg. {\field{\*\fldinst PAGE}{\fldrslt 1}}\sectd\lndscpsxn\pgwsxn16838\pghsxn11906 {\par}} +\pard\plain Volumes table\par +\trowd\cellx3000\cellx6000\pard\intbl A\cell B\cell\row +\pard {\sect } +\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\sbknone\pgnrestart\headery708\footery708\cols2\colsx708\linebetcol +\pard\plain Column text one.\par +\pard\plain Column text two.\par \sect } +\sectd\pgnrestart\headery708\footery708\colsx708 +\pard\plain Closing words\par +\pard\plain Last line.\par}"; + +fn document_xml(docx: &[u8]) -> String { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap(); + let mut xml = String::new(); + zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + xml +} + +#[test] +fn rtf_sections_convert_to_one_docx_section_each() { + let doc = io::open_bytes("sections.rtf", RTF.as_bytes()).unwrap(); + assert_eq!(doc.sections().len(), 4); + let xml = document_xml(&io::save_bytes("sections.docx", &doc).unwrap()); + // One sectPr per section, in order. + let secs: Vec<&str> = xml.split("").next().unwrap_or("")).collect(); + assert_eq!(secs.len(), 4, "{xml}"); + let orient: Vec = secs.iter().map(|s| s.contains(r#"w:orient="landscape""#)).collect(); + assert_eq!(orient, [false, true, true, false]); + // A4 both ways. + assert!(secs[0].contains(r#"w:w="11906""#) && secs[0].contains(r#"w:h="16838""#), "{}", secs[0]); + assert!(secs[1].contains(r#"w:w="16838""#) && secs[1].contains(r#"w:h="11906""#), "{}", secs[1]); + // Section 3: continuous, two columns with a line between. + assert!(secs[2].contains(r#", + /// The section this paragraph ends (RTF `\sect`), as the document's last paragraph of it. + pub section: Option>, } impl Para { @@ -419,6 +421,7 @@ impl Builder<'_> { if p.page_break { out.props.page_break_before = Some(true); } + out.section.clone_from(&p.section); // Inlines are appended in order, so build the runs directly: inserting each one through // `Paragraph::insert_text` rescans every earlier run and is quadratic in their number. for i in &p.inlines { @@ -741,6 +744,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { inlines: Vec::new(), spacing: Spacing::default(), borders: None, + section: None, }; let in_code = kind == Kind::Code; let mut k = 0usize; @@ -761,6 +765,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { inlines: Vec::new(), spacing: Spacing::default(), borders: None, + section: None, }; let prev = std::mem::replace(&mut out, next); if prev.inlines.is_empty() { diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 31320221f..8f5fede5b 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -25,6 +25,8 @@ use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo const MAX_GROUPS: usize = 512; /// Largest picture we decode (bytes). const MAX_PICT: usize = 64 << 20; +/// Most sections we read (`\sect`); later breaks end paragraphs only. +const MAX_SECTIONS: usize = 10_000; // --------------------------------------------------------------------------------------------- // Export @@ -639,6 +641,56 @@ impl PageGeom { } } +/// Section formatting (`\sectd` resets it): the section's page words (`\pgwsxn`…, over the +/// document's `\paperw`…), its break, columns, page numbering and title page. +#[derive(Clone, Debug, Default)] +struct SecFmt { + page: PageGeom, + start: wordcraft_doc::section::SectionStart, + cols: Option, + colsx: Option, + linebetcol: bool, + pgn_restart: bool, + pgn_start: Option, + pgn_fmt: wordcraft_doc::section::NumFormat, + title_page: bool, +} + +impl SecFmt { + /// The section's properties over the document's page (`doc`); headers and footers unset. + fn props(&self, doc: &PageGeom) -> wordcraft_doc::SectionProps { + let p = &self.page; + let page = PageGeom { + w: p.w.or(doc.w), + h: p.h.or(doc.h), + left: p.left.or(doc.left), + right: p.right.or(doc.right), + top: p.top.or(doc.top), + bottom: p.bottom.or(doc.bottom), + header: p.header.or(doc.header), + footer: p.footer.or(doc.footer), + // A section that gives its own page size or orientation has its own orientation; + // the document's `\landscape` only applies to sections that give neither. + landscape: if p.w.is_some() || p.h.is_some() || p.landscape { p.landscape } else { doc.landscape }, + }; + let mut s = page.section(); + s.start = self.start; + if let Some(n) = self.cols { + s.columns.count = n.clamp(1, 45) as u32; + } + if let Some(x) = self.colsx { + s.columns.space = (x as f32 / 20.0).clamp(0.0, 1584.0); + } + s.columns.separator = self.linebetcol; + if self.pgn_restart { + s.page_num_start = Some(self.pgn_start.unwrap_or(1).clamp(0, 32_767) as u32); + } + s.page_num_format = self.pgn_fmt; + s.title_page = self.title_page; + s + } +} + #[derive(Default)] struct Reader { st: St, @@ -654,14 +706,17 @@ struct Reader { ansicpg: u16, /// The default font (`\deff`), whose charset applies before any `\f`. deff: i32, + /// The document's page (`\paperw`, `\margl`…) and the current section's formatting. page: PageGeom, + sec: SecFmt, + /// The headers and footers of each section ended so far (`\sect`); `hf_parts` holds the + /// current section's. + section_parts: Vec)>>, /// The header or footer being read: the group depth it started at, which one, and the /// body's output, put back when the group closes. hf: Option<(usize, HeaderFooter, Box)>, - /// Headers and footers read, in order. + /// The current section's headers and footers, in order. hf_parts: Vec<(HeaderFooter, Vec)>, - /// `\titlepg`: the first page has its own header and footer. - title_page: bool, /// The cell border side being described (`\clbrdrt`, `l`, `b`, `r`): its line style, width /// and colour follow. cell_side: Option, @@ -762,13 +817,75 @@ impl Reader { } self.close_table(); let part = self.swap_output(*body); - // A document names each one once; a repeat (another section's) replaces it. + // A section names each one once; a repeat replaces it. self.hf_parts.retain(|(w, _)| *w != which); if self.hf_parts.len() < 16 { self.hf_parts.push((which, part.body)); } } + /// `\sect`: end the paragraph, and with it the section, which takes its formatting and the + /// headers and footers read since the last break. Inside a header, footer or table, and past + /// [`MAX_SECTIONS`], it only ends the paragraph. + fn end_section(&mut self) { + self.end_para(false); + if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS { + return; + } + let props = self.sec.props(&self.page); + if let Some(FBlock::Para(p)) = self.body.last_mut() { + p.section = Some(Box::new(props)); + self.section_parts.push(std::mem::take(&mut self.hf_parts)); + } + } + + /// A section formatting word: applied to the current section, unless it's inside a header + /// or footer (whose `\sectd` isn't the body's). False when `w` isn't one. + fn section_word(&mut self, w: &str, p: Option) -> bool { + use wordcraft_doc::section::{NumFormat, SectionStart}; + let on = p != Some(0); + let pv = p.unwrap_or(0); + // Inside a header or footer the word is read into a scratch copy and dropped. + let mut scratch; + let sec = if self.hf.is_some() { + scratch = self.sec.clone(); + &mut scratch + } else { + &mut self.sec + }; + match w { + "sectd" => *sec = SecFmt::default(), + "titlepg" => sec.title_page = on, + "pgwsxn" => sec.page.w = Some(pv), + "pghsxn" => sec.page.h = Some(pv), + "marglsxn" => sec.page.left = Some(pv), + "margrsxn" => sec.page.right = Some(pv), + "margtsxn" => sec.page.top = Some(pv), + "margbsxn" => sec.page.bottom = Some(pv), + "headery" => sec.page.header = Some(pv), + "footery" => sec.page.footer = Some(pv), + "lndscpsxn" => sec.page.landscape = on, + "sbknone" => sec.start = SectionStart::Continuous, + "sbkcol" => sec.start = SectionStart::NextColumn, + "sbkpage" => sec.start = SectionStart::NextPage, + "sbkeven" => sec.start = SectionStart::EvenPage, + "sbkodd" => sec.start = SectionStart::OddPage, + "cols" => sec.cols = Some(pv), + "colsx" => sec.colsx = Some(pv), + "linebetcol" => sec.linebetcol = on, + "pgnrestart" => sec.pgn_restart = true, + "pgncont" => sec.pgn_restart = false, + "pgnstarts" => sec.pgn_start = Some(pv), + "pgndec" => sec.pgn_fmt = NumFormat::Decimal, + "pgnucrm" => sec.pgn_fmt = NumFormat::UpperRoman, + "pgnlcrm" => sec.pgn_fmt = NumFormat::LowerRoman, + "pgnucltr" => sec.pgn_fmt = NumFormat::UpperLetter, + "pgnlcltr" => sec.pgn_fmt = NumFormat::LowerLetter, + _ => return false, + } + true + } + fn open(&mut self) { self.skip_chars = 0; if self.stack.len() >= MAX_GROUPS { @@ -1316,7 +1433,7 @@ impl Reader { } match w { "par" => self.end_para(false), - "sect" => self.end_para(false), + "sect" => self.end_section(), "cell" => self.end_cell(), "nestcell" => self.end_para(true), "row" => self.end_row(), @@ -1474,16 +1591,14 @@ impl Reader { b.color = color; } } - "titlepg" => self.title_page = on, - "paperw" | "pgwsxn" => self.page.w = Some(pv), - "paperh" | "pghsxn" => self.page.h = Some(pv), - "margl" | "marglsxn" => self.page.left = Some(pv), - "margr" | "margrsxn" => self.page.right = Some(pv), - "margt" | "margtsxn" => self.page.top = Some(pv), - "margb" | "margbsxn" => self.page.bottom = Some(pv), - "headery" => self.page.header = Some(pv), - "footery" => self.page.footer = Some(pv), - "landscape" | "lndscpsxn" => self.page.landscape = on, + "paperw" => self.page.w = Some(pv), + "paperh" => self.page.h = Some(pv), + "margl" => self.page.left = Some(pv), + "margr" => self.page.right = Some(pv), + "margt" => self.page.top = Some(pv), + "margb" => self.page.bottom = Some(pv), + "landscape" => self.page.landscape = on, + _ if self.section_word(w, p) => {} "cellx" => { self.cell_side = None; let mut d = std::mem::take(&mut self.def_cur); @@ -1550,9 +1665,11 @@ fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option), Str struct Parsed { flow: Flow, page: PageGeom, + /// The last section's properties (the others are on the paragraphs that end them). + last_section: wordcraft_doc::SectionProps, default_font: Option, - headers_footers: Vec<(HeaderFooter, Vec)>, - title_page: bool, + /// Each section's headers and footers, in order, the last section's last. + headers_footers: Vec)>>, } fn parse_full(bytes: &[u8]) -> Result { @@ -1722,11 +1839,22 @@ fn parse_full(bytes: &[u8]) -> Result { r.meta.created = format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59)); } let mut blocks = std::mem::take(&mut r.body); + // A file that ends with `\sect` ends its last section there: no empty one follows, and + // header or footer groups after that `\sect` (a section without text) are dropped. + let mut last_section = r.sec.props(&r.page); + let mut headers_footers = std::mem::take(&mut r.section_parts); + if let Some(FBlock::Para(p)) = blocks.last_mut() + && let Some(s) = p.section.take() + { + last_section = *s; + } else { + headers_footers.push(std::mem::take(&mut r.hf_parts)); + } if blocks.is_empty() { blocks.push(FBlock::Para(Para::default())); } let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned(); - Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, default_font, headers_footers: r.hf_parts, title_page: r.title_page }) + Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }) } /// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid @@ -1768,19 +1896,38 @@ fn column_grid(rows: &mut [Vec], rights: &[Vec]) -> Vec { /// Parse RTF bytes into a document. pub fn import(bytes: &[u8]) -> Result { - let Parsed { flow, page, default_font, headers_footers, title_page } = parse_full(bytes)?; - let mut doc = model::to_doc_in(&flow, page.section()); - for (which, blocks) in &headers_footers { - let blocks = model::part_blocks(&mut doc, blocks); - let id = doc.add_part(if which.footer { wordcraft_doc::PartKind::Footer } else { wordcraft_doc::PartKind::Header }, blocks); - let set = if which.footer { &mut doc.last_section.footers } else { &mut doc.last_section.headers }; - match which.pages { - HfPages::Default => set.default = Some(id), - HfPages::First => set.first = Some(id), - HfPages::Even => set.even = Some(id), + let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?; + let mut doc = model::to_doc_in(&flow, last_section); + // Each section's own headers and footers; those it leaves out stay linked to the previous. + let mut sets = Vec::with_capacity(headers_footers.len()); + for parts in &headers_footers { + let (mut headers, mut footers) = (wordcraft_doc::section::HeaderSet::default(), wordcraft_doc::section::HeaderSet::default()); + for (which, blocks) in parts { + let blocks = model::part_blocks(&mut doc, blocks); + let id = doc.add_part(if which.footer { wordcraft_doc::PartKind::Footer } else { wordcraft_doc::PartKind::Header }, blocks); + let set = if which.footer { &mut footers } else { &mut headers }; + match which.pages { + HfPages::Default => set.default = Some(id), + HfPages::First => set.first = Some(id), + HfPages::Even => set.even = Some(id), + } + } + sets.push((headers, footers)); + } + let mut sets = sets.into_iter(); + for b in &mut doc.body { + if let wordcraft_doc::Block::Para(p) = &**b + && p.section.is_some() + && let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) + && let Some(s) = p.section.as_deref_mut() + && let Some((h, f)) = sets.next() + { + (s.headers, s.footers) = (h, f); } } - doc.last_section.title_page = title_page; + if let Some((h, f)) = sets.next() { + (doc.last_section.headers, doc.last_section.footers) = (h, f); + } // RTF's paragraph defaults are single spacing and no space around paragraphs; what a file // wants beyond that it states on each paragraph. let dp = &mut doc.styles.default_para; @@ -2081,6 +2228,110 @@ mod tests { assert_eq!(texts(&f).last().map(String::as_str), Some("Normal:Café")); } + #[test] + fn sections_keep_their_page_columns_numbering_and_headers() { + use wordcraft_doc::section::{NumFormat, SectionStart}; + // A4 portrait → landscape restarting at 1 with its own header → continuous two columns + // → portrait. A `\sectd` inside a footer group is the footer's, not the body's. + let rtf = br"{\rtf1\ansi\paperw11906\paperh16838\margl1134\margr1134 +\sectd\headery708\footery708 +{\header \pard One head\par} +{\footer \pard Page {\field{\*\fldinst PAGE}{\fldrslt 1}}\sectd\par} +\pard First\par\sect +\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\headery708\pgnrestart\pgnstarts1\pgnlcrm\titlepg +{\header \pard Two head\par} +{\headerf \pard Two first\par} +{\footer \pard Two foot\sectd\lndscpsxn\par} +\pard Second\par +\trowd\cellx2000\pard\intbl cell\cell\row +\pard{\sect} +\sectd\lndscpsxn\pgwsxn16838\pghsxn11906\headery708\sbknone\pgnrestart\cols2\colsx567\linebetcol +\pard Third\sect +\sectd\headery708\sbkodd +\pard Fourth\par}"; + let d = import(rtf).unwrap(); + let secs = d.sections(); + assert_eq!(secs.len(), 4, "{secs:?}"); + let s: Vec<&wordcraft_doc::SectionProps> = secs.iter().map(|(_, s)| *s).collect(); + // Page size and orientation; the document's margins apply to every section. + for (i, land) in [(0, false), (1, true), (2, true), (3, false)] { + assert_eq!(s[i].landscape, land, "section {i}"); + let (w, h) = if land { (841.9, 595.3) } else { (595.3, 841.9) }; + assert!((s[i].page_w - w).abs() < 0.1 && (s[i].page_h - h).abs() < 0.1, "section {i}: {} x {}", s[i].page_w, s[i].page_h); + assert!((s[i].margin_left - 56.7).abs() < 0.1, "section {i}"); + assert!((s[i].header - 35.4).abs() < 0.1, "section {i}"); + } + // Break types. + assert_eq!( + s.iter().map(|s| s.start).collect::>(), + [SectionStart::NextPage, SectionStart::NextPage, SectionStart::Continuous, SectionStart::OddPage] + ); + // Columns. + assert_eq!(s[2].columns.count, 2); + assert!((s[2].columns.space - 28.35).abs() < 0.01 && s[2].columns.separator); + assert_eq!(s[0].columns.count, 1); + assert_eq!(s[3].columns.count, 1); + // Page numbering: restart at 1 (lower roman) in 2 and 3; section 4 carries on. + assert_eq!(s.iter().map(|s| s.page_num_start).collect::>(), [None, Some(1), Some(1), None]); + assert_eq!(s[1].page_num_format, NumFormat::LowerRoman); + assert_eq!(s[0].page_num_format, NumFormat::Decimal); + assert!(!s[0].title_page && s[1].title_page && !s[3].title_page); + // Headers and footers: sections 1 and 2 have their own; 3 and 4 inherit (none set). + let text = |id: Option| { + let p = id.and_then(|id| d.parts.get(&id)).unwrap(); + p.blocks + .iter() + .filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }) + .collect::>() + .join("|") + }; + assert_eq!(text(s[0].headers.default), "One head"); + assert!(text(s[0].footers.default).starts_with("Page")); + assert_eq!(text(s[1].headers.default), "Two head"); + assert_eq!(text(s[1].headers.first), "Two first"); + assert_eq!(text(s[1].footers.default), "Two foot"); + assert!(s[0].headers.first.is_none()); + for i in [2, 3] { + assert_eq!((s[i].headers, s[i].footers), Default::default(), "section {i}"); + } + // Each section ends on its own paragraph, after the text it holds. + let body_text = |i: usize| match d.body.get(i).map(|b| &**b) { + Some(wordcraft_doc::Block::Para(p)) => p.text.clone(), + _ => "".into(), + }; + assert_eq!(body_text(secs[0].0 - 1), "First"); + assert_eq!(body_text(secs[1].0 - 1), "
"); + assert_eq!(body_text(secs[2].0), "Third"); + assert_eq!(body_text(secs[3].0), "Fourth"); + // A landscape document default doesn't turn a section that gives its own portrait page. + let d = import(br"{\rtf1\ansi\paperw16838\paperh11906\landscape\sectd a\sect\sectd\pgwsxn11906\pghsxn16838 b\par}").unwrap(); + let secs = d.sections(); + assert_eq!(secs.len(), 2); + assert!(secs[0].1.landscape && secs[0].1.page_w > secs[0].1.page_h, "{:?}", secs[0].1); + assert!(!secs[1].1.landscape && secs[1].1.page_w < secs[1].1.page_h, "{:?}", secs[1].1); + } + + #[test] + fn sections_are_capped_and_a_trailing_sect_adds_no_empty_section() { + // A file that ends with `\sect` has no empty section after it. + let d = import(br"{\rtf1\ansi\sectd\sbknone a\sect\sectd\lndscpsxn b\sect}").unwrap(); + let secs = d.sections(); + assert_eq!(secs.len(), 2, "{secs:?}"); + assert!(secs[1].1.landscape && !secs[0].1.landscape); + // Hostile: a flood of section breaks with headers is capped and never panics. + let mut rtf = b"{\\rtf1\\ansi".to_vec(); + for _ in 0..(MAX_SECTIONS + 50) { + rtf.extend_from_slice(b"\\sectd\\cols99999\\colsx-5\\pgnrestart\\pgnstarts-7{\\header h\\par}{\\footerl f\\par}x\\sect"); + } + rtf.push(b'}'); + let d = import(&rtf).unwrap(); + let secs = d.sections(); + assert_eq!(secs.len(), MAX_SECTIONS); + // Each section keeps its own header and footer; the ones past the cap replace the last's. + assert!(d.parts.len() <= 2 * MAX_SECTIONS, "{}", d.parts.len()); + assert!(secs.iter().all(|(_, s)| s.columns.count <= 45 && s.columns.space >= 0.0 && s.page_num_start == Some(0))); + } + #[test] fn rejects_non_rtf() { assert!(parse(b"hello").is_err()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d1b245728..bcfc850dd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; 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-14 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; previously trivial: RTF import keeps sections, #1378; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -25,7 +25,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, RTL | +| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, revisions, section export, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | @@ -170,6 +170,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | | 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) | +| 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | diff --git a/docs/gaps.md b/docs/gaps.md index 0afe4d636..1ec89efd2 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-13 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; previously trivial: Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; 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-14 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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. @@ -33,7 +33,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions, sections (RTF import done, #1378; RTF export and ODT left), RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | @@ -84,6 +84,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word | | 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | | 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | +| 2026-10-14 | trivial | RTF import keeps sections (#1378): gap 19 narrowed to RTF section export and ODT sections | | 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps | | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | | 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells (into or out of a table, whole rows); removed from the UI/UX gaps | From 686f71cabb9cdd6e37bc6a7680c25df31b609d7a Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:44:21 +0200 Subject: [PATCH 067/199] Save As: fill the file type list (#1399) (#1497) * #1399: WIP * Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- apps/wordcraft/src/file_dialogs.rs | 60 ++++++++++++++++++++++++++++-- docs/ui-parity.md | 5 ++- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 473ca1107..839e0a781 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-14 · **Change:** trivial (progress log: rounded rectangle corners, #1476; previously trivial: progress log: RTF import keeps sections, #1378; 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-14 · **Change:** 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. @@ -165,6 +165,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height | | 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again | | 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's | +| 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -335,6 +336,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells | | 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 | | 2026-10-12 | trivial | Progress log: Undo history list (#701) | +| 2026-10-14 | trivial | Progress log: Save As type list (#1399) | | 2026-10-12 | trivial | Progress log: Start screen at launch (#766) | | 2026-10-13 | trivial | Progress log: no document behind the Start screen (#1129) | | 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed | diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 651cddf4e..89613f191 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -58,8 +58,11 @@ impl Launcher { let picking: Picking = match req { FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()), FileDialogRequest::Save { name } => { - let exts = save_exts(&name); - Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file()) + let mut dialog = dialog.set_file_name(name.clone()); + for (label, exts) in save_filters(&name) { + dialog = dialog.add_filter(label, &exts); + } + Box::pin(dialog.save_file()) } }; let ctx = self.ctx.clone(); @@ -158,9 +161,60 @@ fn save_exts(name: &str) -> Vec { exts } +/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see +/// [`save_exts`] for why the order matters). +/// +/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx" +/// in the name box, the extension in the name wins, as the format is chosen by extension. +fn save_filters(name: &str) -> Vec<(String, Vec)> { + save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect() +} + +/// A readable name for a save format's extension. +fn format_name(ext: &str) -> String { + match ext { + "docx" => "Word Document".into(), + "docm" => "Word Macro-Enabled Document".into(), + "dotx" => "Word Template".into(), + "dotm" => "Word Macro-Enabled Template".into(), + "pdf" => "PDF".into(), + "txt" => "Plain Text".into(), + "md" => "Markdown".into(), + "html" => "Web Page".into(), + "rtf" => "Rich Text Format".into(), + "odt" => "OpenDocument Text".into(), + "tex" => "LaTeX".into(), + "png" => "PNG Image".into(), + "json" => "JSON".into(), + "xml" => "XML".into(), + other => other.to_ascii_uppercase(), + } +} + #[cfg(test)] mod tests { - use super::save_exts; + use super::{save_exts, save_filters}; + + /// #1399: the Save dialog's type list has one named entry per format, the suggested one first, + /// every savable extension exactly once. + #[test] + fn save_dialog_lists_one_named_type_per_format() { + let filters = save_filters("report.odt"); + assert_eq!(filters.len(), wordcraft_engine::io::SAVE_EXTS.len()); + assert_eq!(filters[0].1, vec!["odt".to_string()]); + assert!(filters[0].0.contains("OpenDocument"), "{}", filters[0].0); + let all: Vec<&String> = filters.iter().flat_map(|(_, e)| e).collect(); + for e in wordcraft_engine::io::SAVE_EXTS { + assert_eq!(all.iter().filter(|x| x.as_str() == *e).count(), 1, "{e}"); + } + assert!(filters.iter().all(|(l, e)| !l.trim().is_empty() && e.len() == 1)); + let mut labels: Vec<&String> = filters.iter().map(|(l, _)| l).collect(); + labels.sort(); + labels.dedup(); + assert_eq!(labels.len(), filters.len(), "labels are distinct"); + // Unknown names fall back to Word's format first. + assert_eq!(save_filters("no extension")[0].1, vec!["docx".to_string()]); + } /// #689: the Save dialog's default extension is the suggested name's own format, so a file /// name the user types without one is saved in the document's format instead of coming back diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 158743b53..776e3828c 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-13 · **Change:** trivial (full screen on F11, #980; previously trivial: double-click the margin to create and edit a header or footer, #960; previously trivial: Review › Language and the status bar's language menu open Set Proofing Language… and Language Preferences…, #860; 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-14 · **Change:** trivial (full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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 @@ -38,8 +38,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 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 | -| File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — | | 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% | — | +| File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94), its Save As type list naming every format with the document's own first (#1399); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — | | Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 | | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); not yet tried with real screen readers | full | 50% | 5–8 | | Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization | @@ -53,6 +53,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | +| 2026-10-14 | trivial | File dialogs: the system Save As type list names each format, the document's own first (#1399) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | From bf2bd694707e478fdb5acf0ccd80fc679abfa9c5 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:58:14 +0200 Subject: [PATCH 068/199] HTML import: bold header cells, cell alignment and the region paper (#1381) (#1494) * #1381: WIP * #1381: HTML import: bold , cell text-align, region paper for HTML/MD/TXT Header cells are bold unless their own CSS says otherwise. text-align on a td/th, or inherited from its tr/row group/table, aligns the cell's paragraphs; a paragraph's own alignment wins, and a table's align attribute (which places the table) no longer counts. Files that give no page size (HTML, Markdown, plain text) open on the region's paper, as a new blank document does: file.open uses the session's region, wordcraft-cli reads the system region the way the app does (Region::from_locale_vars, shared with ui-egui). Tables size to that page. Co-Authored-By: Claude Opus 5.5 * #1381: the CLI uses the system region everywhere; help mentions WORDCRAFT_REGION Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + ROADMAP.md | 4 +- apps/wordcraft-cli/Cargo.toml | 1 + apps/wordcraft-cli/src/main.rs | 33 +++++-- apps/wordcraft-cli/tests/convert_region.rs | 53 +++++++++++ crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/io.rs | 52 ++++++++-- crates/engine/src/io_ext.rs | 7 +- crates/engine/src/tests.rs | 105 +++++++++++++++++++++ crates/formats/src/html.rs | 95 ++++++++++++++++++- crates/formats/src/lib.rs | 15 +++ crates/formats/src/markdown.rs | 5 + crates/formats/src/txt.rs | 7 ++ crates/geom/src/region.rs | 34 +++++++ crates/ui-egui/src/i18n/mod.rs | 20 +--- docs/file-format-parity.md | 5 +- 16 files changed, 397 insertions(+), 44 deletions(-) create mode 100644 apps/wordcraft-cli/tests/convert_region.rs diff --git a/Cargo.lock b/Cargo.lock index c5a92f472..503ba4373 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5133,6 +5133,7 @@ name = "wordcraft-cli" version = "0.7.0" dependencies = [ "serde_json", + "sys-locale", "wordcraft-doc", "wordcraft-engine", "wordcraft-mcp", diff --git a/ROADMAP.md b/ROADMAP.md index 839e0a781..9c896ed00 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-14 · **Change:** 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; 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. @@ -152,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | +| 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | | 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 | | 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | | 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | @@ -317,6 +318,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) | | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | +| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | | 2026-10-13 | trivial | Progress log: full screen on F11 (#980) | | 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | | 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | diff --git a/apps/wordcraft-cli/Cargo.toml b/apps/wordcraft-cli/Cargo.toml index 354b80bc7..7f9b25dfa 100644 --- a/apps/wordcraft-cli/Cargo.toml +++ b/apps/wordcraft-cli/Cargo.toml @@ -20,3 +20,4 @@ wordcraft-render = { workspace = true } wordcraft-mcp = { workspace = true } wordcraft-zotero = { workspace = true } serde_json = { workspace = true } +sys-locale = "0.3" diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 0f3dfec38..6a9d44a3b 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -43,15 +43,36 @@ USAGE: addNote, refresh, removeCodes, setDocPrefs (--cmd runs first, e.g. --cmd caret.docEnd) wordcraft-cli --version + +Paper: documents created here, and HTML, Markdown and text files (which give no page size), use +the system region's paper (A4, or Letter in the US and other Letter regions), read from +LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the +same output on every machine. "; fn open(path: &str) -> Result { - let doc = wordcraft_engine::io::open_path(std::path::Path::new(path))?; - let mut s = Session::new(doc); + // A file that gives no page size (HTML, Markdown, plain text) opens on the system region's + // paper, as in the app: A4 in Italy, Letter in the US (#1381). + let region = system_region(); + let (doc, _) = wordcraft_engine::io::open_path_in(std::path::Path::new(path), None, ®ion)?; + let mut s = session(doc); s.path = Some(path.into()); Ok(s) } +/// A headless session in the system region: every way the CLI starts one gives `file.new` the +/// same paper (#1381). +fn session(doc: wordcraft_doc::Document) -> Session { + let mut s = Session::new(doc); + s.system_region = system_region(); + s +} + +/// The system region, read as the app reads it (locale variables, else the platform's locale). +fn system_region() -> wordcraft_engine::Region { + wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale) +} + /// The body text for `text`: the plain-text export, so list items keep their bullet or number label. fn listed_text(doc: &wordcraft_doc::Document) -> Result { let bytes = wordcraft_engine::io::save_bytes("out.txt", doc)?; @@ -245,11 +266,11 @@ fn run(args: &[String]) -> Result<(), Failure> { let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) { (Some(f), _) => open(&f)?, (None, Some(t)) => { - let mut s = Session::new(wordcraft_doc::Document::new()); + let mut s = session(wordcraft_doc::Document::new()); s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?; s } - _ => Session::new(wordcraft_doc::Document::new()), + _ => session(wordcraft_doc::Document::new()), }; if let Some(name) = arg_value(&rest, "--author") { set_author(&mut s, &name)?; @@ -291,7 +312,7 @@ fn run(args: &[String]) -> Result<(), Failure> { } "commands" => { validate_options("commands", &rest, COMMANDS_OPTIONS)?; - let s = Session::new(wordcraft_doc::Document::new()); + let s = session(wordcraft_doc::Document::new()); if rest.iter().any(|a| a == "--json") { let list = serde_json::to_string_pretty(&s.registry.describe()).unwrap_or_default(); write_stdout(|o| writeln!(o, "{list}")) @@ -306,7 +327,7 @@ fn run(args: &[String]) -> Result<(), Failure> { } "parity" => { validate_options("parity", &rest, PARITY_OPTIONS)?; - let s = Session::new(wordcraft_doc::Document::new()); + let s = session(wordcraft_doc::Document::new()); let p = wordcraft_engine::catalog::parity(&s.registry); if rest.iter().any(|a| a == "--markdown") { write_stdout(|o| write!(o, "{}", parity_markdown(&p))) diff --git a/apps/wordcraft-cli/tests/convert_region.rs b/apps/wordcraft-cli/tests/convert_region.rs new file mode 100644 index 000000000..5f9fb4b53 --- /dev/null +++ b/apps/wordcraft-cli/tests/convert_region.rs @@ -0,0 +1,53 @@ +//! `wordcraft-cli convert page.html out.docx` puts an HTML file, which gives no page size, on the +//! system region's paper, as the app does: A4 in Italy, Letter in the US (#1381). + +use std::process::Command; + +const HTML: &str = "

Premessa

\ +
IndicatoreValore
Audit4
"; + +/// Run the CLI in `region` with `args` (`{src}` is the sample HTML, `{out}` a JSON file it +/// writes) and read back the page size of the JSON it saved. +fn page_size_of(region: &str, args: &[&str]) -> (f64, f64) { + let dir = std::env::temp_dir().join(format!("wordcraft-cli-region-{region}-{}-{}", args.len(), std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (src, out) = (dir.join("page.html"), dir.join("out.json")); + std::fs::write(&src, HTML).unwrap(); + let args: Vec = args.iter().map(|a| a.replace("{src}", &src.to_string_lossy()).replace("{out}", &out.to_string_lossy())).collect(); + let run = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(&args).env("WORDCRAFT_REGION", region).env_remove("LC_ALL").output().unwrap(); + assert!(run.status.success(), "{args:?}: {}", String::from_utf8_lossy(&run.stderr)); + let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + let sp = &doc["lastSection"]; + (sp["pageW"].as_f64().unwrap(), sp["pageH"].as_f64().unwrap()) +} + +/// Convert the sample to JSON in `region` and read back the page size. +fn page_size(region: &str) -> (f64, f64) { + page_size_of(region, &["convert", "{src}", "{out}"]) +} + +fn is_a4((w, h): (f64, f64)) -> bool { + (w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01 +} + +#[test] +fn html_converts_onto_the_region_paper() { + let (w, h) = page_size("it-IT"); + assert!(is_a4((w, h)), "A4 in Italy: {w} x {h}"); + assert_eq!(page_size("en-US"), (612.0, 792.0), "Letter in the US"); +} + +#[test] +fn every_run_session_follows_the_region() { + // `run --file`, `run --template` and a bare `run` all start a session in the system region, + // so `file.new` gives the same paper whichever way the session began. + for args in [ + &["run", "--cmd", "file.new", "--save", "{out}"][..], + &["run", "--template", "blank", "--save", "{out}"][..], + &["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..], + ] { + assert!(is_a4(page_size_of("it-IT", args)), "A4 in Italy: {args:?} gave {:?}", page_size_of("it-IT", args)); + assert_eq!(page_size_of("en-US", args), (612.0, 792.0), "Letter in the US: {args:?}"); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 86d58d17f..bc83f5613 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -174,9 +174,9 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { let password = p::str(v, "password"); let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") { let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; - crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)? + crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)? } else { - crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)? + crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)? }; // Plain text and Markdown carry no language: they are in the editing language. let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7cedc2a83..c29499d4c 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -107,7 +107,21 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { /// whether the file was encrypted. Without the password needed the error satisfies /// [`needs_password`]; with a wrong one, [`wrong_password`]. pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { - let (doc, encrypted) = read_bytes(name, bytes, password)?; + open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default()) +} + +/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s +/// paper, as a new blank document (#838, #1381), with the default margins. +pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionProps { + let (_, w, h) = region.paper(); + wordcraft_doc::SectionProps { page_w: w, page_h: h, ..Default::default() } +} + +/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper +/// (see [`region_page`]); a document with its own page size keeps it. +pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { + let page = region_page(region); + let (doc, encrypted) = read_bytes(name, bytes, password, &page)?; load_embedded_fonts(&doc); Ok((doc, encrypted)) } @@ -122,7 +136,7 @@ fn load_embedded_fonts(doc: &Document) { } } -fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { +fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> { let ext = ext_of(name); if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?; @@ -130,15 +144,15 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Docum return Ok((doc, true)); } if ext.is_empty() || signature_contradicts(&ext, bytes) { - return open_by_content(name, &ext, bytes, password); + return open_by_content(name, &ext, bytes, password, page); } let mut doc = match ext.as_str() { // Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252. - "txt" | "text" | "" => wordcraft_formats::txt::import(bytes), + "txt" | "text" | "" => wordcraft_formats::txt::import_on(bytes, page), "json" => serde_json::from_slice::(bytes).map_err(|e| format!("{name}: {e}"))?, - other => match crate::io_ext::open(other, bytes) { + other => match crate::io_ext::open(other, bytes, page) { Some(r) => r?, - None => return open_by_content(name, other, bytes, password), + None => return open_by_content(name, other, bytes, password, page), }, }; doc.ensure_nonempty(); @@ -211,7 +225,13 @@ fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { /// opened by what it holds, as Word does: a Word document or RTF file by its signature, any /// other text through the plain-text import with encoding detection (#569). Anything else is /// refused with a clear error rather than shown as garbage. -fn open_by_content(name: &str, ext: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { +fn open_by_content( + name: &str, + ext: &str, + bytes: &[u8], + password: Option<&str>, + page: &wordcraft_doc::SectionProps, +) -> Result<(Document, bool), String> { const ZIP: &[u8] = b"PK\x03\x04"; const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; let (mut doc, encrypted) = if bytes.starts_with(OLE) && wordcraft_docx::is_encrypted(bytes) { @@ -223,7 +243,7 @@ fn open_by_content(name: &str, ext: &str, bytes: &[u8], password: Option<&str>) } else if bytes.starts_with(b"{\\rtf") { (wordcraft_formats::rtf::import(bytes)?, false) } else if wordcraft_formats::txt::is_text(bytes) { - (wordcraft_formats::txt::import(bytes), false) + (wordcraft_formats::txt::import_on(bytes, page), false) } else if ext.is_empty() { return Err(format!("{name}: not a document or text file WordCraft can open")); } else { @@ -272,6 +292,12 @@ pub fn open_path(path: &std::path::Path) -> Result { /// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]). #[cfg(not(target_arch = "wasm32"))] pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> { + open_path_in(path, password, &wordcraft_geom::Region::default()) +} + +/// [`open_path_with`] in `region` (see [`open_bytes_in`]). +#[cfg(not(target_arch = "wasm32"))] +pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { crate::file_access::check_read(&path.to_string_lossy())?; let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; if meta.len() > 2 << 30 { @@ -282,12 +308,13 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result< if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") { // Pictures referenced by a relative path load from beside the HTML file. let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new(""))); - let mut doc = wordcraft_formats::html::import_with(&wordcraft_formats::html::decode(&bytes), &|src| images.load(src)); + let html = wordcraft_formats::html::decode(&bytes); + let mut doc = wordcraft_formats::html::import_on(&html, &|src| images.load(src), ®ion_page(region)); fit_pictures(&mut doc); doc.ensure_nonempty(); return Ok((doc, false)); } - open_bytes_with(&name, &bytes, password) + open_bytes_in(&name, &bytes, password, region) } /// The pictures an HTML file names by a path relative to its folder. @@ -451,6 +478,11 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result Err(format!("{}: files are opened through the browser on the web", path.display())) } +#[cfg(target_arch = "wasm32")] +pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { + open_path_with(path, password) +} + pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> { save_path_with(path, doc, None, &ExportOptions::default()) } diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 12eb6dccb..aea98a343 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -2,15 +2,16 @@ use wordcraft_doc::Document; -/// Open a format other than plain text / JSON. -pub fn open(ext: &str, bytes: &[u8]) -> Option> { +/// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML) +/// start on `page`. +pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { match ext { "docx" | "docm" | "dotx" | "dotm" => Some(wordcraft_docx::read(bytes).map_err(|e| e.to_string())), "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), // HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`). - other => wordcraft_formats::import(other, bytes).map(|r| { + other => wordcraft_formats::import_on(other, bytes, page).map(|r| { r.map(|mut doc| { crate::io::fit_pictures(&mut doc); doc diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3fd3c1269..9b3eb63d7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5389,6 +5389,111 @@ fn insert_picture_takes_a_windows_metafile() { let _ = std::fs::remove_dir_all(&dir); } +/// #1381: the reporter's sample HTML (synthetic, 1.7 KB). +const SAMPLE_1381: &str = r##"Testo iniziale +

Premessa

+

Il presente documento descrive l’organizzazione della struttura sanitaria di prova e le misure +adottate per la gestione del rischio clinico. I contenuti sono sintetici +e servono solo a verificare la resa della conversione.

+

Le attività sono svolte secondo le raccomandazioni ministeriali vigenti e sono oggetto +di revisione annuale da parte del comitato per la valutazione dei sinistri.

+
    +
  • Identificazione dei rischi per reparto
  • +
  • Analisi degli eventi avversi e dei near miss +
    • segnalazione spontanea
    • revisione delle cartelle cliniche
  • +
  • Piano annuale di miglioramento
  • +
+
    +
  1. Raccolta delle segnalazioni
  2. +
  3. Classificazione dell’evento
  4. +
  5. Azioni correttive
  6. +
+ + + + + + +
IndicatoreValore attesoValore rilevato
Segnalazioni analizzate entro 30 giorni≥ 90%93%
Audit clinici svolti45
Totale azioni correttive aperte12
+

Fonte: elaborazione interna su dati sintetici.

+"##; + +/// The first table's grid width in points. +fn first_table_width(doc: &wordcraft_doc::Document) -> f32 { + doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.grid.iter().sum::()) } else { None }).unwrap() +} + +#[test] +fn opened_html_text_and_markdown_get_the_region_paper() { + // #1381: a file that gives no page size opens on the region's paper, as a new blank document + // does (#838): A4 in Italy, Letter in the US. Tables fit that page. + let dir = std::env::temp_dir().join(format!("wordcraft-region-paper-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let html = dir.join("S05.html"); + std::fs::write(&html, SAMPLE_1381).unwrap(); + for (tag, size) in [("it-IT", (595.28, 841.89)), ("en-US", (612.0, 792.0))] { + let mut s = s(); + s.system_region = wordcraft_geom::Region::from_tag(tag); + // By path (pictures load from beside the file) and by bytes (the web, MCP). + run(&mut s, "file.open", json!({"path": html.to_string_lossy()})); + let sp = s.doc.last_section.clone(); + assert_eq!((sp.page_w, sp.page_h), size, "{tag}: HTML by path"); + assert!(first_table_width(&s.doc) <= sp.text_width() + 0.5, "{tag}: the table fits the page"); + for (name, body) in [("S05.html", SAMPLE_1381), ("notes.txt", "Hello"), ("notes.md", "# Title\n\n|a|b|\n|-|-|\n|1|2|\n")] { + let data = crate::cmd::insert::base64_encode(body.as_bytes()); + run(&mut s, "file.open", json!({"path": name, "data": data})); + let sp = &s.doc.last_section; + assert_eq!((sp.page_w, sp.page_h), size, "{tag}: {name}"); + } + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn opened_documents_with_their_own_page_size_keep_it() { + // #1381: only files without a page size take the region's paper. + for (w, h) in [(500.0, 700.0), (612.0, 792.0)] { + let mut doc = wordcraft_doc::Document::from_text("Own size"); + doc.last_section.page_w = w; + doc.last_section.page_h = h; + for name in ["own.docx", "own.rtf", "own.odt"] { + let bytes = crate::io::save_bytes(name, &doc).unwrap(); + let mut s = s(); + s.system_region = wordcraft_geom::Region::from_tag("it-IT"); + run(&mut s, "file.open", json!({"path": name, "data": crate::cmd::insert::base64_encode(&bytes)})); + let sp = &s.doc.last_section; + assert_eq!((sp.page_w.round(), sp.page_h.round()), (w, h), "{name}"); + } + } +} + +#[test] +fn html_converted_to_docx_keeps_bold_headers_and_right_aligned_cells() { + // #1381: `wordcraft-cli convert S05.html out.docx`: the runs are bold and the + // `text-align:right` cells' paragraphs carry `w:jc w:val="right"`. + use std::io::Read; + let mut s = s(); + run(&mut s, "file.open", json!({"path": "S05.html", "data": crate::cmd::insert::base64_encode(SAMPLE_1381.as_bytes())})); + let bytes = crate::io::save_bytes("out.docx", &s.doc).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + let cell = |text: &str| { + let at = xml.find(&format!(">{text}<")).unwrap_or_else(|| panic!("{text} in {xml}")); + let start = xml[..at].rfind("").or_else(|| xml[..at].rfind("").unwrap(); + xml[start..end].to_string() + }; + for head in ["Indicatore", "Valore atteso", "Valore rilevato"] { + assert!(cell(head).contains(""), "{head}: {}", cell(head)); + } + for right in ["93%", "4", "5", "12"] { + assert!(cell(right).contains(r#""#), "{right}: {}", cell(right)); + } + let left = cell("Audit clinici svolti"); + assert!(!left.contains(r#""#) && !left.contains(""), "{left}"); +} + #[test] fn exported_page_images_draw_headers_at_full_strength() { // file.exportPng (and `wordcraft-cli render`) drew headers and footers dimmed, as the screen diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 5df84a0aa..536a46542 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -607,7 +607,22 @@ impl<'r> Builder<'r> { let mut lists = 0usize; for (i, e) in self.stack.iter().enumerate().rev() { match e.kind { - ElKind::Cell | ElKind::Table => break, + ElKind::Cell => { + // CSS inherits `text-align` from the cell, its row, row group and table + // (#1381); the paragraph's own (found above) wins. + if p.align.is_none() { + p.align = self.stack.get(..=i).and_then(|outer| { + outer + .iter() + .rev() + .take_while(|o| o.kind != ElKind::Table) + .chain(outer.iter().rev().find(|o| o.kind == ElKind::Table)) + .find_map(|o| o.align) + }); + } + break; + } + ElKind::Table => break, ElKind::Heading(n) if p.kind == Kind::Normal => p.kind = Kind::Heading(n.clamp(1, 6)), ElKind::Pre if p.kind == Kind::Normal => p.kind = Kind::Code, ElKind::Quote if p.kind == Kind::Normal => p.kind = Kind::Quote, @@ -979,7 +994,8 @@ impl<'r> Builder<'r> { let mut preserve = self.preserve(); let mut page_break = false; match name { - "b" | "strong" => fmt.bold = true, + // A header cell is bold, as in browsers and Word, unless its own CSS says otherwise. + "b" | "strong" | "th" => fmt.bold = true, "i" | "em" | "cite" | "var" | "dfn" | "address" => fmt.italic = true, "u" | "ins" => fmt.underline = true, "s" | "strike" | "del" => fmt.strike = true, @@ -1010,7 +1026,9 @@ impl<'r> Builder<'r> { } _ => {} } - if let Some(a) = attr(attrs, "align") { + // `align` on a table is about where the table sits (not imported); it doesn't align the + // text in its cells. + if let Some(a) = attr(attrs, "align").filter(|_| kind != ElKind::Table) { align = match a.to_ascii_lowercase().as_str() { "center" | "middle" => Some(Align::Center), "right" => Some(Align::Right), @@ -1237,6 +1255,12 @@ pub fn import_with(s: &str, images: ImageLoader) -> Document { model::to_doc(&parse_with(s, images)) } +/// [`import_with`] onto `page` (its size and margins), which tables size to: HTML gives no page +/// size, so the caller picks it (the region's paper, #1381). +pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document { + model::to_doc_in(&parse_with(s, images), page.clone()) +} + // --------------------------------------------------------------------------------------------- // Export @@ -1683,6 +1707,71 @@ mod tests { assert!(p.text().ends_with("l\nn")); } + /// The paragraphs of every cell of the first table, row by row. + fn cell_paras(f: &Flow) -> Vec>> { + let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; + t.rows + .iter() + .map(|r| { + r.iter().map(|c| c.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.clone()) } else { None }).collect()).collect() + }) + .collect() + } + + fn all_bold(p: &Para) -> bool { + !p.inlines.is_empty() && p.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.bold)) + } + + fn any_bold(p: &Para) -> bool { + p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) if f.bold)) + } + + #[test] + fn header_cells_are_bold_unless_their_css_says_otherwise() { + // #1381: a is bold, as in browsers and Word; `font-weight:normal` on it or inside it wins. + let f = parse( + "
HeadPlainInner
abc
", + ); + let rows = cell_paras(&f); + assert!(all_bold(&rows[0][0][0]), "{:?}", rows[0][0]); + assert!(!any_bold(&rows[0][1][0]), "{:?}", rows[0][1]); + assert!(!any_bold(&rows[0][2][0]), "{:?}", rows[0][2]); + assert!(!any_bold(&rows[1][0][0]), "td stays regular"); + } + + #[test] + fn cell_text_align_reaches_the_cell_paragraphs() { + // #1381: `text-align` on a td/th, or inherited from its tr/thead/tbody/table, aligns the + // cell's paragraphs; a paragraph's own text-align inside the cell wins. + let f = parse( + "\ + \ +
rinherited

own

attr
body
table
", + ); + let rows = cell_paras(&f); + assert_eq!(rows[0][0][0].align, Some(Align::Right), "td style"); + assert_eq!(rows[0][1][0].align, Some(Align::Right), "from tr"); + assert_eq!(rows[0][2][0].align, Some(Align::Center), "own paragraph wins"); + assert_eq!(rows[0][3][0].align, Some(Align::Right), "align attribute"); + assert_eq!(rows[1][0][0].align, Some(Align::Justify), "from tbody"); + assert_eq!(rows[2][0][0].align, Some(Align::Center), "from table"); + // `align` on a table places the table; it doesn't align the text in its cells. + let f = parse("
x
"); + assert_eq!(cell_paras(&f)[0][0][0].align, None); + } + + #[test] + fn reporter_sample_header_bold_and_right_aligned_cells() { + // #1381: the reporter's table. + let f = parse( + "\n\n\n\n
IndicatoreValore atteso
Audit4
", + ); + let rows = cell_paras(&f); + assert!(all_bold(&rows[0][0][0]) && all_bold(&rows[0][1][0]), "{:?}", rows[0]); + assert_eq!(rows[1][1][0].align, Some(Align::Right)); + assert_eq!(rows[1][0][0].align, None); + } + #[test] fn entities() { assert_eq!(unescape("a <b> AB &bogus; &"), "a AB &bogus; &"); diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index be4f3cabd..a8254ae71 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -43,6 +43,21 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option> { })) } +/// [`import`] onto `page` (its size and margins) for the formats whose files give no page size: +/// plain text, Markdown and HTML start on it, and their tables size to it (#1381). The other +/// formats keep the page their file gives. +pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { + let ext = ext.trim_start_matches('.').to_ascii_lowercase(); + let mut doc = match ext.as_str() { + "txt" | "text" => txt::import_on(bytes, page), + "md" | "markdown" => markdown::import_on(&txt::decode(bytes), page), + "html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page), + _ => return import(&ext, bytes), + }; + doc.ensure_nonempty(); + Some(Ok(doc)) +} + /// Export `doc` in the format named by `ext`. `None` = not our format. pub fn export(ext: &str, doc: &Document) -> Option, String>> { let ext = ext.trim_start_matches('.').to_ascii_lowercase(); diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 5a71b44f2..1476c6b0b 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -25,6 +25,11 @@ pub fn import(text: &str) -> Document { model::to_doc(&parse(text)) } +/// [`import`] onto `page`: Markdown gives no page size (#1381). +pub fn import_on(text: &str, page: &wordcraft_doc::SectionProps) -> Document { + model::to_doc_in(&parse(text), page.clone()) +} + /// Parse Markdown text into the flow model. pub fn parse(text: &str) -> model::Flow { let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); diff --git a/crates/formats/src/txt.rs b/crates/formats/src/txt.rs index 51ed4cdcc..82d546bb3 100644 --- a/crates/formats/src/txt.rs +++ b/crates/formats/src/txt.rs @@ -89,6 +89,13 @@ pub fn import(bytes: &[u8]) -> Document { Document::from_text(&clean) } +/// [`import`] onto `page`: plain text gives no page size (#1381). +pub fn import_on(bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Document { + let mut doc = import(bytes); + doc.last_section = page.clone(); + doc +} + fn blocks_text(blocks: &[FBlock], out: &mut Vec, lists: &mut ListCounter, depth: usize) { for b in blocks { match b { diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index 6d99a926c..6ceae0b86 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -119,6 +119,20 @@ impl Region { self } + /// The system region from the locale variables `var` reads (the process environment) and, + /// when none is set, the platform's first preferred locale `platform` gives. One tag for + /// everything first (`WORDCRAFT_REGION`, then `LC_ALL`); otherwise Unix sets each convention + /// on its own: numbers (`LC_NUMERIC`, else `LANG`), paper (`LC_PAPER`) and measurement + /// (`LC_MEASUREMENT`). Empty, `C` and `POSIX` values count as unset. + pub fn from_locale_vars(var: impl Fn(&str) -> Option, platform: impl FnOnce() -> Option) -> Region { + let var = |name: &str| var(name).filter(|v| usable_locale(v)); + if let Some(tag) = var("WORDCRAFT_REGION").or_else(|| var("LC_ALL")) { + return Region::from_tag(&tag); + } + let base = var("LC_NUMERIC").or_else(|| var("LANG")).unwrap_or_else(|| platform().unwrap_or_default()); + Region::from_tag(&base).with_paper_and_measurement(var("LC_PAPER").as_deref(), var("LC_MEASUREMENT").as_deref()) + } + /// Does a new document start on US Letter here (otherwise A4)? pub fn uses_letter(&self) -> bool { self.letter.unwrap_or_else(|| LETTER.contains(&self.territory.as_str())) @@ -151,6 +165,12 @@ impl Region { } } +/// A locale variable that names a locale (not empty, `C` or `POSIX`). +fn usable_locale(v: &str) -> bool { + let v = v.trim(); + !v.is_empty() && !v.eq_ignore_ascii_case("c") && !v.eq_ignore_ascii_case("posix") && !v.starts_with("C.") +} + #[cfg(test)] mod tests { use super::*; @@ -195,4 +215,18 @@ mod tests { assert_eq!(Region::from_tag("pl-PL").with_paper_and_measurement(Some(""), Some("junk")).paper().0, "Letter", "junk is en-US"); assert_eq!(us.format(Unit::Inches, 90.0), "1.25\""); } + + #[test] + fn region_from_locale_variables() { + // #1381: the CLI and the app read the system region the same way. + let env = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string()); + let none = || None; + assert_eq!(Region::from_locale_vars(env(&[("LANG", "it_IT.UTF-8")]), none).paper().0, "A4"); + assert_eq!(Region::from_locale_vars(env(&[("LANG", "en_US.UTF-8")]), none).paper().0, "Letter"); + assert_eq!(Region::from_locale_vars(env(&[("LANG", "it_IT.UTF-8"), ("LC_ALL", "en_US")]), none).paper().0, "Letter"); + assert_eq!(Region::from_locale_vars(env(&[("LANG", "en_US"), ("WORDCRAFT_REGION", "de-DE")]), none).territory, "DE"); + assert_eq!(Region::from_locale_vars(env(&[("LANG", "en_US"), ("LC_PAPER", "it_IT")]), none).paper().0, "A4"); + assert_eq!(Region::from_locale_vars(env(&[("LANG", "C.UTF-8")]), || Some("it-IT".into())).territory, "IT"); + assert_eq!(Region::from_locale_vars(env(&[]), none), Region::default()); + } } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 63d28e078..2a34553b1 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -294,23 +294,9 @@ pub fn system_region() -> wordcraft_engine::Region { if cfg!(test) { return wordcraft_engine::Region::default(); } - let var = |name: &str| std::env::var(name).ok().filter(|v| usable_locale(v)); - // One tag for everything: an explicit override, or `LC_ALL`. - if let Some(tag) = var("WORDCRAFT_REGION").or_else(|| var("LC_ALL")) { - return wordcraft_engine::Region::from_tag(&tag); - } - // Unix sets each convention on its own: numbers (`LC_NUMERIC`, else `LANG`), paper - // (`LC_PAPER`) and measurement (`LC_MEASUREMENT`). Elsewhere, and when none is set, the - // platform's first preferred locale (on macOS it carries the region, as in `en-PL`; on the web - // it is the browser's language). - let base = var("LC_NUMERIC").or_else(|| var("LANG")).unwrap_or_else(|| sys_locale::get_locale().unwrap_or_default()); - wordcraft_engine::Region::from_tag(&base).with_paper_and_measurement(var("LC_PAPER").as_deref(), var("LC_MEASUREMENT").as_deref()) -} - -/// A locale variable that names a locale (not empty, `C` or `POSIX`). -fn usable_locale(v: &str) -> bool { - let v = v.trim(); - !v.is_empty() && !v.eq_ignore_ascii_case("c") && !v.eq_ignore_ascii_case("posix") && !v.starts_with("C.") + // The environment's locale variables (Unix), else the platform's first preferred locale (on + // macOS it carries the region, as in `en-PL`; on the web it is the browser's language). + wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale) } thread_local! { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bcfc850dd..cd4a4761e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (rounded rectangles keep their own corner radius, #1476; previously trivial: RTF import keeps sections, #1378; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-14 · **Change:** trivial (HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: rounded rectangles keep their own corner radius, #1476; previously trivial: RTF import keeps sections, #1378; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -28,7 +28,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, revisions, section export, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | -| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | +| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | @@ -169,6 +169,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | +| 2026-10-14 | trivial | HTML: `` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) | | 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) | | 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | From 42c1f71b52f361475ef4df9291d46ccce8926dcb Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:58:33 +0200 Subject: [PATCH 069/199] =?UTF-8?q?Custom=20page=20sizes:=20Size=20?= =?UTF-8?q?=E2=80=BA=20More=20Paper=20Sizes=20and=20Page=20Setup=20width/h?= =?UTF-8?q?eight=20(#1433)=20(#1496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #1433: WIP * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * #1433: one OK is one undo step; errors stay in the dialog Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/page.rs | 54 +++- crates/engine/src/tests.rs | 66 +++++ crates/ui-egui/src/dialogs.rs | 444 ++++++++++++++++++++++++++-- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/et.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sr-latn.tsv | 5 + crates/ui-egui/src/i18n/sr.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/ribbon.rs | 28 +- docs/ui-parity.md | 5 +- 25 files changed, 653 insertions(+), 45 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9c896ed00..6fdcdba2d 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: custom page size, #1433; 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. @@ -153,6 +153,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | +| 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either | | 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 | | 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | | 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | @@ -319,6 +320,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | +| 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) | | 2026-10-13 | trivial | Progress log: full screen on F11 (#980) | | 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | | 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 2e9b7f4fa..b3cbcd6f2 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { with_sect(s, |x| x.set_landscape(land)) }) .params(r#"{"value": "portrait|landscape"}"#), - CommandSpec::new("layout.size", "Size", "Layout › Page Setup", size).params(r#"{"name"?: "Letter|Legal|A4|…", "width"?: pt, "height"?: pt}"#), + CommandSpec::new("layout.size", "Size", "Layout › Page Setup", size).params(r#"{"name"?: "Letter|Legal|A4|…", "width"?: pt, "height"?: pt} (each side clamped to 7.2–1584 pt, 0.1–22 in; the section keeps its orientation)"#), CommandSpec::new("layout.columns", "Columns", "Layout › Page Setup", columns) .params( r#"{"count": 1-12, "space"?: pt, "separator"?: bool, "preset"?: "left|right", "widths"?: [[width pt, space after pt], …] (unequal columns; sets the count), "apply"?: "section|document|forward"}"#, @@ -225,15 +225,19 @@ pub fn sect(s: &Session) -> SectionProps { s.doc.sections().get(i).map(|(_, x)| (*x).clone()).unwrap_or_default() } -/// Apply to the section(s) of the selection. -pub(crate) fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdResult { +/// The last block of each section of the selection. +fn selected_section_ends(s: &Session) -> Vec { let (a, b) = s.sel.ordered(); let (i0, i1) = (a.path.0.first().copied().unwrap_or(0) as usize, b.path.0.first().copied().unwrap_or(0) as usize); let ends: Vec = s.doc.sections().iter().map(|(e, _)| *e).collect(); let first = ends.iter().position(|e| i0 <= *e).unwrap_or(0); let last = ends.iter().position(|e| i1 <= *e).unwrap_or(first); - for k in first..=last { - let block = ends.get(k).copied().unwrap_or(0); + (first..=last).map(|k| ends.get(k).copied().unwrap_or(0)).collect() +} + +/// Apply to the section(s) of the selection. +pub(crate) fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdResult { + for block in selected_section_ends(s) { f(s.doc.section_mut(block)); } Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null)) @@ -355,7 +359,7 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult { }; let gutter = p::f32(v, "gutter"); let cur = sect(s); - if l + r + gutter.unwrap_or(cur.gutter) > cur.page_w - 36.0 || t + b > cur.page_h - 36.0 { + 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())); } // A preset picks mirrored or not; plain numbers (a ruler drag, an agent) keep the setting. @@ -373,6 +377,23 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult { }) } +/// Why `layout.size` refuses a page its margins don't fit. +pub const MARGINS_DONT_FIT: &str = "The margins don't fit on a page this size; make the margins smaller first."; + +/// Text room the margins must leave on a page, each way, points. +pub const MIN_TEXT_ROOM: f32 = 36.0; + +/// Whether margins (top, bottom, left, right) and a gutter leave text room on a page (width, +/// height), all in points: the rule `layout.margins` and `layout.size` both apply, and Page +/// Setup checks before it runs either. +pub fn margins_fit(page: (f32, f32), (t, b, l, r): (f32, f32, f32, f32), gutter: f32) -> bool { + l + r + gutter <= page.0 - MIN_TEXT_ROOM && t + b <= page.1 - MIN_TEXT_ROOM +} + +/// The smallest and largest page side, points (0.1" and 22", as Word allows). +pub const MIN_PAGE_SIDE: f32 = 7.2; +pub const MAX_PAGE_SIDE: f32 = 1584.0; + fn size(s: &mut Session, v: &Value) -> CmdResult { let (w, h) = if let Some(n) = p::str(v, "name") { let (_, w, h) = wordcraft_geom::PAPER_SIZES @@ -383,14 +404,21 @@ fn size(s: &mut Session, v: &Value) -> CmdResult { } else { (p::req_f32(v, "width")?, p::req_f32(v, "height")?) }; - if !(72.0..=1584.0).contains(&w) || !(72.0..=1584.0).contains(&h) { - return Err(CmdError::Params("page size must be 1\"–22\"".into())); + // Word's range per side is 0.1"–22" (#1433); `p::f32` already refused NaN and infinities. + let (w, h) = (w.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE), h.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE)); + // Each section keeps its orientation: the long side across in landscape, down in portrait. + let oriented = |x: &SectionProps| if x.landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) }; + let sections = s.doc.sections(); + let fits = |block: usize| { + sections + .iter() + .find(|(e, _)| *e == block) + .is_none_or(|(_, x)| margins_fit(oriented(x), (x.margin_top, x.margin_bottom, x.margin_left, x.margin_right), x.gutter)) + }; + if !selected_section_ends(s).into_iter().all(fits) { + return Err(CmdError::Params(MARGINS_DONT_FIT.into())); } - with_sect(s, |x| { - let (pw, ph) = if x.landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) }; - x.page_w = pw; - x.page_h = ph; - }) + with_sect(s, |x| (x.page_w, x.page_h) = oriented(x)) } fn columns(s: &mut Session, v: &Value) -> CmdResult { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9b3eb63d7..eec8ffffa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1512,6 +1512,71 @@ fn page_setup_and_breaks() { assert!(s.run("layout.margins", &json!({"left": 1000.0})).is_err()); } +/// #1433: a custom page size (More Paper Sizes…) sets the section in its orientation, and +/// out-of-range sizes clamp to Word's 0.1"–22" instead of failing or reaching the layout. +#[test] +fn layout_size_takes_a_custom_size_and_clamps_it() { + let mut s = s(); + let size = |s: &Session| (s.doc.last_section.page_w, s.doc.last_section.page_h); + run(&mut s, "layout.size", json!({"width": 300.0, "height": 500.0})); + assert_eq!(size(&s), (300.0, 500.0)); + run(&mut s, "layout.margins", json!({"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0})); + run(&mut s, "layout.size", json!({"width": 40.0, "height": 5000.0})); + assert_eq!(size(&s), (40.0, 1584.0), "clamped to 22\""); + for junk in [ + json!({"width": 1e300, "height": 400.0}), + json!({"width": "NaN", "height": 400.0}), + json!({"width": null}), + json!({}), + // Clamped to 0.1", still less than the 36 pt of text room the margins need. + json!({"width": -50.0, "height": 400.0}), + json!({"width": 1.0, "height": 400.0}), + ] { + assert!(s.run("layout.size", &junk).is_err(), "{junk}"); + assert_eq!(size(&s), (40.0, 1584.0), "{junk} leaves the size alone"); + } + // A landscape section keeps its orientation: the long side goes across. + run(&mut s, "layout.orientation", json!({"value": "landscape"})); + run(&mut s, "layout.size", json!({"width": 300.0, "height": 500.0})); + assert_eq!(size(&s), (500.0, 300.0)); + assert!(s.doc.last_section.landscape); +} + +/// #1433: `layout.size` refuses a page its margins don't fit, by the rule `layout.margins` uses +/// (36 pt of text room each way), and changes nothing. +#[test] +fn layout_size_refuses_a_page_its_margins_dont_fit() { + let mut s = s(); + run(&mut s, "layout.margins", json!({"preset": "normal"})); + let before = s.doc.last_section.clone(); + for (w, h) in [(100.0, 500.0), (500.0, 100.0), (179.0, 500.0)] { + let e = s.run("layout.size", &json!({"width": w, "height": h})).unwrap_err().to_string(); + assert!(e.contains("margins don't fit"), "{w}×{h}: {e}"); + assert_eq!(s.doc.last_section, before, "{w}×{h} changes nothing"); + } + run(&mut s, "layout.size", json!({"width": 180.0, "height": 180.0})); + 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"); +} + +/// #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] +fn a_tiny_page_lays_out_and_exports() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "A paragraph of text that cannot fit on so small a page."})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 3})); + run(&mut s, "text.insert", json!({"text": "cell"})); + run(&mut s, "layout.columns", json!({"count": 2})); + let mut sect = s.doc.last_section.clone(); + (sect.page_w, sect.page_h) = (7.2, 40.0); + (sect.margin_top, sect.margin_bottom, sect.margin_left, sect.margin_right, sect.gutter) = (0.0, 0.0, 0.0, 0.0, 0.0); + run(&mut s, "layout.pageSetup", json!({"section": serde_json::to_value(§).unwrap()})); + assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h), (7.2, 40.0)); + assert!(!s.layout().pages.is_empty()); + let pdf = crate::io::save_bytes("tiny.pdf", &s.doc).unwrap(); + assert!(pdf.starts_with(b"%PDF")); +} + #[test] fn track_changes_and_accept() { let mut s = s(); @@ -2141,6 +2206,7 @@ fn hostile_params_never_panic() { json!({}), json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}), json!({"value": -1e308, "rows": 1e9}), + json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}), ]; for spec in reg.all() { // These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4537d5ec4..dddf40d01 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -246,6 +246,14 @@ pub enum Dialog { grid_was: (u8, u32, u32), /// Section direction: right to left (`layout.sectionRtl`). rtl: bool, + /// Paper width and height in the measurement unit as shown (landscape: the wide side + /// first); `paper_was` as opened (`layout.size` runs only if it changed, #1433). + paper: (f32, f32), + paper_was: (f32, f32), + /// Custom size chosen in Paper size: shown even while the numbers match a named size. + paper_custom: bool, + /// Why OK didn't apply (the margins don't fit the paper). + message: String, }, Link { url: String, @@ -1074,7 +1082,12 @@ impl Dialog { }; let size = app.session.doc.styles.resolve_char(Some("Normal"), &Default::default()).size; let grid = (kind, sp.grid_lines_per_page().min(1000), sp.grid_chars_per_line(size).min(1000)); + let paper = (sp.page_w / crate::options::unit().pt_per_unit(), sp.page_h / crate::options::unit().pt_per_unit()); Dialog::PageSetup { + paper, + paper_was: paper, + paper_custom: false, + message: String::new(), top: sp.margin_top / crate::options::unit().pt_per_unit(), bottom: sp.margin_bottom / crate::options::unit().pt_per_unit(), left: sp.margin_left / crate::options::unit().pt_per_unit(), @@ -1387,6 +1400,126 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { } } +/// Labels of Page Setup's paper size (#1433), in one place in case Word's wording differs. +pub(crate) const PAPER_LABELS: PaperLabels = + PaperLabels { group: "Paper", size: "Paper size:", custom: "Custom size", width: "Width:", height: "Height:", more: "More Paper Sizes…" }; + +pub(crate) struct PaperLabels { + pub group: &'static str, + pub size: &'static str, + pub custom: &'static str, + pub width: &'static str, + pub height: &'static str, + /// The Size menu's last item, opening Page Setup. + pub more: &'static str, +} + +/// The named paper size `paper` (in the measurement unit, either orientation) is, or None for a +/// custom size. +pub(crate) fn paper_entry(paper: (f32, f32)) -> Option<&'static str> { + let ppu = crate::options::unit().pt_per_unit(); + wordcraft_geom::paper_name(paper.0 * ppu, paper.1 * ppu) +} + +/// The commands Page Setup's OK runs for the paper, orientation and margins (#1433), all in the +/// measurement unit as the dialog shows them. Refuses when the margins don't fit the paper. +/// Otherwise it orders them so that none is refused on the way: the size first +/// when the section's margins fit it, else the margins first, else margins of 0 in between. +/// `layout.size` keeps the section's orientation; `layout.orientation` then turns the page and +/// its margins to what the dialog shows. A size equal to the opened one (either way round, +/// within 0.01 pt) doesn't run `layout.size`. +fn page_layout_commands( + app: &WordApp, + (paper, was): ((f32, f32), (f32, f32)), + landscape: bool, + (top, bottom, left, right): (f32, f32, f32, f32), +) -> Result, &'static str> { + use wordcraft_engine::cmd::page::{MARGINS_DONT_FIT, margins_fit, sect}; + let ppu = crate::options::unit().pt_per_unit(); + let (paper, was) = ((paper.0 * ppu, paper.1 * ppu), (was.0 * ppu, was.1 * ppu)); + let m = (top * ppu, bottom * ppu, left * ppu, right * ppu); + let cur = sect(&app.session); + if !margins_fit(paper, m, cur.gutter) { + return Err(MARGINS_DONT_FIT); + } + let turned = |on: bool, (a, b): (f32, f32)| if on { (a.max(b), a.min(b)) } else { (a.min(b), a.max(b)) }; + let ((w0, h0), (w1, h1)) = (turned(false, paper), turned(false, was)); + let new_size = (w0 - w1).abs() >= 0.01 || (h0 - h1).abs() >= 0.01; + let size = ("layout.size", json!({"width": paper.0, "height": paper.1})); + let orientation = ("layout.orientation", json!({"value": if landscape { "landscape" } else { "portrait" }})); + let margins = ("layout.margins", json!({"top": m.0, "bottom": m.1, "left": m.2, "right": m.3})); + let old_margins = (cur.margin_top, cur.margin_bottom, cur.margin_left, cur.margin_right); + Ok(if !new_size || margins_fit(turned(cur.landscape, paper), old_margins, cur.gutter) { + let mut v = if new_size { vec![size] } else { Vec::new() }; + v.extend([orientation, margins]); + v + } else if margins_fit(turned(landscape, (cur.page_w, cur.page_h)), m, cur.gutter) { + vec![orientation, margins, size] + } else { + vec![orientation, ("layout.margins", json!({"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0})), size, margins] + }) +} + +/// Run a dialog's commands as one undo step. If one fails, put the document and the undo stack +/// back as they were (nothing applied) and return its error. +fn run_as_one_step(app: &mut WordApp, commands: Vec<(&str, Value)>) -> Result<(), String> { + let snapshot = app.session.edit_snapshot(); + for (i, (id, params)) in commands.into_iter().enumerate() { + if i > 0 { + app.session.join_next_undo(); + } + if let Err(e) = app.run(id, params) { + app.session.restore(snapshot); + return Err(e); + } + } + Ok(()) +} + +/// Page Setup's Paper group (#1433): a Paper size list (the named sizes and Custom size) and +/// the width and height. The fields follow the orientation (the wide side is the width in +/// landscape), and a typed size that is wider than tall turns the orientation to landscape. +fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, custom: &mut bool) { + if (*landscape && paper.0 < paper.1) || (!*landscape && paper.0 > paper.1) { + *paper = (paper.1, paper.0); + } + let unit = crate::options::unit(); + let ppu = unit.pt_per_unit(); + let l = &PAPER_LABELS; + ui.label(egui::RichText::new(tl!(l.group)).font(semibold(12.5))); + ui.horizontal(|ui| { + let label = ui.label(tl!(l.size)); + let entry = paper_entry(*paper).filter(|_| !*custom); + let shown = tl!(entry.unwrap_or(l.custom)); + let combo = egui::ComboBox::from_id_salt("ps_paper_size").width(180.0).height(400.0).selected_text(shown).show_ui(ui, |ui| { + for (n, w, h) in wordcraft_geom::PAPER_SIZES { + if ui.selectable_label(entry == Some(*n), tl!(n)).clicked() { + let (w, h) = (w / ppu, h / ppu); + *paper = if *landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) }; + *custom = false; + } + } + // Custom size keeps the numbers to edit, and stays chosen while they match a name. + if ui.selectable_label(entry.is_none(), tl!(l.custom)).clicked() { + *custom = true; + } + }); + combo.response.labelled_by(label.id); + }); + egui::Grid::new("ps_paper").num_columns(4).show(ui, |ui| { + use wordcraft_engine::cmd::page::{MAX_PAGE_SIDE, MIN_PAGE_SIDE}; + let range = MIN_PAGE_SIDE / ppu..=MAX_PAGE_SIDE / ppu; + ui.label(tl!(l.width)); + let w = ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2)); + ui.label(tl!(l.height)); + let h = ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)); + if (w.changed() || h.changed()) && paper.0 != paper.1 { + *landscape = paper.0 > paper.1; + } + ui.end_row(); + }); +} + pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { let mut r = (false, false); ui.add_space(8.0); @@ -1868,7 +2001,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - Dialog::PageSetup { top, bottom, left, right, landscape, rtl, grid, grid_was } => { + Dialog::PageSetup { top, bottom, left, right, landscape, rtl, grid, grid_was, paper, paper_was, paper_custom, message } => { ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5))); egui::Grid::new("ps").num_columns(4).show(ui, |ui| { ui.label(tl!("Top:")); @@ -1889,6 +2022,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.radio_value(landscape, false, tl!("Portrait")); ui.radio_value(landscape, true, tl!("Landscape")); }); + page_paper_group(ui, paper, landscape, paper_custom); ui.label(egui::RichText::new(tl!("Document Grid")).font(semibold(12.5))); ui.horizontal_wrapped(|ui| { ui.radio_value(&mut grid.0, 0, tl!("No grid")); @@ -1908,24 +2042,34 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.radio_value(rtl, false, tl!("Left-to-right")); ui.radio_value(rtl, true, tl!("Right-to-left")); }); - let (ok, cancel) = buttons(ui, tl!("OK")); + if !message.is_empty() { + ui.label(egui::RichText::new(message.as_str()).small().color(ui.visuals().error_fg_color)); + } + let (mut ok, cancel) = buttons(ui, tl!("OK")); if ok { - let _ = app.run("layout.orientation", json!({"value": if *landscape { "landscape" } else { "portrait" }})); - let _ = - app.run("layout.margins", json!({"top": *top * crate::options::unit().pt_per_unit(), "bottom": *bottom * crate::options::unit().pt_per_unit(), "left": *left * crate::options::unit().pt_per_unit(), "right": *right * crate::options::unit().pt_per_unit()})); - if grid != grid_was { - let kind = ["none", "lines", "linesAndChars", "snapToChars"].get(grid.0 as usize).copied().unwrap_or("none"); - let mut v = json!({"type": kind}); - if grid.1 != grid_was.1 { - v["linesPerPage"] = json!(grid.1); - } - if grid.2 != grid_was.2 { - v["charsPerLine"] = json!(grid.2); - } - let _ = app.run("layout.documentGrid", v); - } - if *rtl != wordcraft_engine::cmd::page::sect(&app.session).rtl { - let _ = app.run("layout.sectionRtl", json!({"value": *rtl})); + // Everything OK changes is one undo step; a refused command applies nothing. + let result = page_layout_commands(app, (*paper, *paper_was), *landscape, (*top, *bottom, *left, *right)) + .map_err(|e| tl!(e).to_string()) + .and_then(|mut commands| { + if grid != grid_was { + let kind = ["none", "lines", "linesAndChars", "snapToChars"].get(grid.0 as usize).copied().unwrap_or("none"); + let mut v = json!({"type": kind}); + if grid.1 != grid_was.1 { + v["linesPerPage"] = json!(grid.1); + } + if grid.2 != grid_was.2 { + v["charsPerLine"] = json!(grid.2); + } + commands.push(("layout.documentGrid", v)); + } + if *rtl != wordcraft_engine::cmd::page::sect(&app.session).rtl { + commands.push(("layout.sectionRtl", json!({"value": *rtl}))); + } + run_as_one_step(app, commands) + }); + if let Err(e) = result { + *message = e; + ok = false; } } ok || cancel @@ -3705,4 +3849,268 @@ mod tests { assert!((after.min.x - (before.min.x - 200.0)).abs() <= 1.0, "moved left: {before:?} -> {after:?}"); assert!((after.min.y - (before.min.y + 120.0)).abs() <= 1.0, "moved down: {before:?} -> {after:?}"); } + + fn app_harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> { + 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); + }, + app, + ) + } + + /// The Page Setup paper size as opened, in points, and the Paper size entry it shows. + fn opened_paper(app: &mut WordApp) -> ((f32, f32), Option<&'static str>) { + let Some(Dialog::PageSetup { paper, .. }) = Dialog::open("pageSetup", app) else { panic!("no page setup dialog") }; + let ppu = crate::options::unit().pt_per_unit(); + ((paper.0 * ppu, paper.1 * ppu), paper_entry(paper)) + } + + /// #1433: Page Setup's Paper size shows the named size the section has, in either + /// orientation, and Custom size (None) for anything else. + #[test] + fn page_setup_reads_the_paper_size() { + let mut app = app_with("Page"); + let sect = |app: &WordApp| wordcraft_engine::cmd::page::sect(&app.session); + let ((w, h), entry) = opened_paper(&mut app); + assert!((w - sect(&app).page_w).abs() < 0.01 && (h - sect(&app).page_h).abs() < 0.01, "{w}×{h}"); + assert_eq!(entry, wordcraft_geom::paper_name(w, h)); + assert!(entry.is_some(), "a new document has a named size"); + let _ = app.run("layout.size", json!({"name": "A4"})); + let _ = app.run("layout.orientation", json!({"value": "landscape"})); + let ((w, h), entry) = opened_paper(&mut app); + assert!(w > h, "landscape shows the wide side as the width: {w}×{h}"); + assert_eq!(entry, Some("A4")); + let _ = app.run("layout.size", json!({"width": 300.0, "height": 500.0})); + assert_eq!(opened_paper(&mut app).1, None, "Custom size"); + } + + /// #1433: a custom width and height applied with OK set the section's size (before the + /// orientation, so a wide custom size ends up landscape); an unchanged size doesn't run + /// `layout.size`, and switching orientation alone only turns the page. + #[test] + fn page_setup_applies_a_custom_paper_size() { + use egui_kittest::kittest::Queryable; + let ppu = crate::options::unit().pt_per_unit(); + let size_steps = |app: &WordApp| app.session.undo_labels().iter().filter(|l| l.as_str() == "Size").count(); + let sect = |app: &WordApp| { + let s = wordcraft_engine::cmd::page::sect(&app.session); + (s.page_w.round(), s.page_h.round(), s.landscape) + }; + // OK on an unchanged dialog leaves the size alone. + let mut h = app_harness(app_with("Page")); + let before = sect(h.state()); + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!((sect(h.state()), size_steps(h.state())), (before, 0), "no layout.size for an unchanged size"); + + for ((w, ht), want) in [((400.0, 600.0), (400.0, 600.0, false)), ((800.0, 400.0), (800.0, 400.0, true))] { + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(2); + if let Some(Dialog::PageSetup { paper, landscape, .. }) = h.state_mut().dialog.as_mut() { + *paper = (w / ppu, ht / ppu); + *landscape = w > ht; + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(sect(h.state()), want, "{w}×{ht}"); + } + assert_eq!(size_steps(h.state()), 2); + + // Portrait again with the same paper: the dialog swaps width and height, which is no new size. + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(2); + if let Some(Dialog::PageSetup { paper, landscape, .. }) = h.state_mut().dialog.as_mut() { + *paper = (paper.1, paper.0); + *landscape = false; + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(sect(h.state()), (400.0, 800.0, false)); + assert_eq!(size_steps(h.state()), 2, "turning the page isn't a new size"); + } + + /// #1433: the Paper group's own widgets: choosing A4 in Paper size fills Width and Height, + /// Landscape swaps them, OK gives an A4 landscape section; typing a width wider than the + /// height turns the orientation to Landscape and shows Custom size; choosing Custom size + /// keeps the numbers and stays chosen. + #[test] + fn page_setup_paper_widgets_work_together() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let ppu = crate::options::unit().pt_per_unit(); + let mut h = app_harness(app_with("Page")); + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(3); + let state = |h: &egui_kittest::Harness<'_, WordApp>| match &h.state().dialog { + Some(Dialog::PageSetup { paper, landscape, .. }) => { + ((paper.0 * ppu * 100.0).round() / 100.0, (paper.1 * ppu * 100.0).round() / 100.0, *landscape) + } + _ => panic!("no page setup dialog"), + }; + let combo = |h: &egui_kittest::Harness<'_, WordApp>| h.get_by_role_and_label(Role::ComboBox, "Paper size:").value(); + assert_eq!(combo(&h).as_deref(), Some("Letter")); + h.get_by_role_and_label(Role::ComboBox, "Paper size:").click(); + h.run_steps(3); + h.get_by_label("A4").click(); + h.run_steps(3); + assert_eq!(state(&h), (595.28, 841.89, false), "A4 fills the fields"); + assert_eq!(combo(&h).as_deref(), Some("A4")); + h.get_by_label("Landscape").click(); + h.run_steps(3); + assert_eq!(state(&h), (841.89, 595.28, true), "Landscape swaps them"); + assert_eq!(combo(&h).as_deref(), Some("A4")); + h.get_by_label("OK").click(); + h.run_steps(3); + let sect = wordcraft_engine::cmd::page::sect(&h.state().session); + assert_eq!(((sect.page_w * 100.0).round() / 100.0, (sect.page_h * 100.0).round() / 100.0, sect.landscape), (841.89, 595.28, true)); + + // Portrait A4 again; a width wider than the height turns it to Landscape, Custom size. + h.state_mut().run("layout.orientation", json!({"value": "portrait"})).unwrap(); + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(3); + h.get_by_value("8.27\"").focus(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("15".into())); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(state(&h), (1080.0, 841.89, true), "wider than tall: Landscape"); + assert_eq!(combo(&h).as_deref(), Some("Custom size")); + + // Back to A4, then Custom size: the numbers stay and so does the choice. + h.get_by_role_and_label(Role::ComboBox, "Paper size:").click(); + h.run_steps(3); + h.get_by_label("A4").click(); + h.run_steps(3); + h.get_by_role_and_label(Role::ComboBox, "Paper size:").click(); + h.run_steps(3); + h.get_by_label("Custom size").click(); + h.run_steps(3); + assert_eq!(state(&h), (841.89, 595.28, true)); + assert_eq!(combo(&h).as_deref(), Some("Custom size")); + } + + /// #1433: Page Setup checks the new size and margins together: a page its margins don't fit + /// keeps the dialog open with a message and applies nothing; a smaller page with smaller + /// margins applies (margins first, so no step is refused on the way). + #[test] + fn page_setup_refuses_margins_that_dont_fit_and_applies_smaller_ones_first() { + use egui_kittest::kittest::Queryable; + let ppu = crate::options::unit().pt_per_unit(); + let mut h = app_harness(app_with("Page")); + let before = wordcraft_engine::cmd::page::sect(&h.state().session); + let undo = h.state().session.undo_depth(); + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(2); + if let Some(Dialog::PageSetup { paper, .. }) = h.state_mut().dialog.as_mut() { + *paper = (100.0 / ppu, 500.0 / ppu); + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!( + matches!(&h.state().dialog, Some(Dialog::PageSetup { message, .. }) if message.contains("margins don't fit")), + "stays open with a message" + ); + assert_eq!((wordcraft_engine::cmd::page::sect(&h.state().session), h.state().session.undo_depth()), (before, undo), "nothing applied"); + + if let Some(Dialog::PageSetup { top, bottom, left, right, .. }) = h.state_mut().dialog.as_mut() { + (*top, *bottom, *left, *right) = (18.0 / ppu, 18.0 / ppu, 18.0 / ppu, 18.0 / ppu); + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + let s = wordcraft_engine::cmd::page::sect(&h.state().session); + assert_eq!((s.page_w.round(), s.page_h.round(), s.margin_left.round(), s.margin_top.round()), (100.0, 500.0, 18.0, 18.0)); + } + + /// Open Page Setup in `h`, set its paper and margins (points) and press OK. + fn page_setup_ok(h: &mut egui_kittest::Harness<'_, WordApp>, (w, ht): (f32, f32), (t, b, l, r): (f32, f32, f32, f32)) { + use egui_kittest::kittest::Queryable; + let ppu = crate::options::unit().pt_per_unit(); + h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap(); + h.run_steps(2); + if let Some(Dialog::PageSetup { paper, landscape, top, bottom, left, right, .. }) = h.state_mut().dialog.as_mut() { + *paper = (w / ppu, ht / ppu); + *landscape = w > ht; + (*top, *bottom, *left, *right) = (t / ppu, b / ppu, l / ppu, r / ppu); + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + } + + /// #1433: one OK in Page Setup is one undo step, whichever order its commands run in (the + /// size first; the margins first; margins of 0 in between), and Undo restores the section. + #[test] + fn page_setup_ok_is_one_undo_step() { + let sect = |h: &egui_kittest::Harness<'_, WordApp>| wordcraft_engine::cmd::page::sect(&h.state().session); + // Size first, then orientation and margins. + let mut h = app_harness(app_with("Page")); + let (before, depth) = (sect(&h), h.state().session.undo_depth()); + page_setup_ok(&mut h, (400.0, 600.0), (36.0, 36.0, 36.0, 36.0)); + assert!(h.state().dialog.is_none()); + assert_eq!((sect(&h).page_w, sect(&h).margin_left), (400.0, 36.0)); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(sect(&h), before, "one undo restores the section"); + + // Neither size first nor margins first fits on the way: margins of 0 in between. + h.state_mut().run("layout.margins", json!({"top": 72.0, "bottom": 72.0, "left": 250.0, "right": 250.0})).unwrap(); + let (before, depth) = (sect(&h), h.state().session.undo_depth()); + page_setup_ok(&mut h, (300.0, 900.0), (400.0, 400.0, 36.0, 36.0)); + assert!(h.state().dialog.is_none()); + let s = sect(&h); + assert_eq!((s.page_w, s.page_h, s.margin_top, s.margin_left), (300.0, 900.0, 400.0, 36.0)); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(sect(&h), before, "one undo restores the section"); + } + + /// #1433: a command the dialog runs that fails (here `layout.size`, refused by the second + /// selected section's margins) shows its error, keeps the dialog open and leaves the + /// document and the undo stack as they were. + #[test] + fn page_setup_shows_a_refused_command_and_applies_nothing() { + let mut app = app_with("one"); + app.run("layout.break", json!({"kind": "nextPage"})).unwrap(); + app.run("text.insert", json!({"text": "two"})).unwrap(); + app.run("layout.margins", json!({"left": 150.0, "right": 150.0})).unwrap(); + // Both sections selected, the first one's settings in the dialog. + app.run("select.all", json!({})).unwrap(); + let sel = &mut app.session.sel; + std::mem::swap(&mut sel.anchor, &mut sel.focus); + let doc = app.session.doc.sections().iter().map(|(_, x)| (*x).clone()).collect::>(); + let depth = app.session.undo_depth(); + let mut h = app_harness(app); + page_setup_ok(&mut h, (300.0, 500.0), (72.0, 72.0, 72.0, 72.0)); + assert!( + matches!(&h.state().dialog, Some(Dialog::PageSetup { message, .. }) if message.contains("margins don't fit")), + "stays open with the error" + ); + let after = h.state().session.doc.sections().iter().map(|(_, x)| (*x).clone()).collect::>(); + assert_eq!((after, h.state().session.undo_depth()), (doc, depth), "nothing applied"); + } + + /// #1433: Layout › Size › More Paper Sizes… opens Page Setup. + #[test] + fn more_paper_sizes_opens_page_setup() { + use egui_kittest::kittest::Queryable; + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(400.0, 600.0)) + .build_ui_state(|ui, app: &mut WordApp| crate::ribbon::size_menu(ui, app), app_with("Page")); + h.run_steps(2); + h.get(egui_kittest::kittest::By::new().label_contains("More Paper Sizes…")).click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("pageSetup")); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index faef7ef61..1cf83e0bb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2467,6 +2467,11 @@ Leave out the date and time of comments and tracked changes when saving Без дата и час на коментарите и проследените промени при запазване The document you are editing keeps the names and dates; only the saved file leaves them out. Документът, който редактирате, запазва имената и датите; само записаният файл ги пропуска. Privacy Settings Текущи настройки за поверителност + Paper Хартия + Paper size: Размер на хартията: + Custom size Потребителски размер + More Paper Sizes… Още размери на хартията… + The margins don't fit on a page this size; make the margins smaller first. Полетата не се побират на страница с този размер; първо намалете полетата. Full Screen Цял екран Full\nScreen Цял\nекран Use the browser's full screen (F11) Използвайте режима на цял екран на браузъра (F11) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4c9f6235b..e58338a8a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2092,6 +2092,11 @@ Leave out the date and time of comments and tracked changes when saving Při ukládání vynechat datum a čas komentářů a sledovaných změn The document you are editing keeps the names and dates; only the saved file leaves them out. Upravovaný dokument si jména a data ponechá; vynechá je jen uložený soubor. Privacy Settings Aktuální nastavení soukromí + Paper Papír + Paper size: Velikost papíru: + Custom size Vlastní velikost + More Paper Sizes… Další velikosti papíru… + The margins don't fit on a page this size; make the margins smaller first. Okraje se na stránku této velikosti nevejdou; nejprve okraje zmenšete. Full Screen Celá obrazovka Full\nScreen Celá\nobrazovka Use the browser's full screen (F11) Použijte celou obrazovku prohlížeče (F11) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index da8dfc74a..1da97e88b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2086,6 +2086,11 @@ Leave out the date and time of comments and tracked changes when saving Beim Speichern Datum und Uhrzeit von Kommentaren und nachverfolgten Änderungen weglassen The document you are editing keeps the names and dates; only the saved file leaves them out. Das bearbeitete Dokument behält Namen und Daten; nur die gespeicherte Datei lässt sie weg. Privacy Settings Aktuelle Datenschutzeinstellungen + Paper Papier + Paper size: Papierformat: + Custom size Benutzerdefiniertes Format + More Paper Sizes… Weitere Papierformate… + The margins don't fit on a page this size; make the margins smaller first. Die Ränder passen nicht auf eine Seite dieser Größe; verkleinern Sie zuerst die Ränder. Full Screen Vollbild Full\nScreen Voll-\nbild Use the browser's full screen (F11) Verwenden Sie den Vollbildmodus des Browsers (F11) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ebd4b24e5..8e4970d93 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2453,6 +2453,11 @@ preview Title Título Leave out the date and time of comments and tracked changes when saving Omitir la fecha y la hora de los comentarios y los cambios controlados al guardar The document you are editing keeps the names and dates; only the saved file leaves them out. El documento que editas conserva los nombres y las fechas; solo el archivo guardado los omite. Privacy Settings Configuración de privacidad actual + Paper Papel + Paper size: Tamaño del papel: + Custom size Tamaño personalizado + More Paper Sizes… Más tamaños de papel… + The margins don't fit on a page this size; make the margins smaller first. Los márgenes no caben en una página de este tamaño; reduzca primero los márgenes. Full Screen Pantalla completa Full\nScreen Pantalla\ncompleta Use the browser's full screen (F11) Use la pantalla completa del navegador (F11) diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 8ca557d77..f5a44f431 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2121,6 +2121,11 @@ Leave out the date and time of comments and tracked changes when saving Jäta salvestamisel välja kommentaaride ja jälitatud muudatuste kuupäev ja kellaaeg The document you are editing keeps the names and dates; only the saved file leaves them out. Redigeeritav dokument säilitab nimed ja kuupäevad; need jäetakse välja ainult salvestatud failist. Privacy Settings Praegused privaatsussätted + Paper Paber + Paper size: Paberi suurus: + Custom size Kohandatud suurus + More Paper Sizes… Rohkem paberisuurusi… + The margins don't fit on a page this size; make the margins smaller first. Veerised ei mahu selle suurusega lehele; vähendage esmalt veeriseid. Full Screen Täisekraan Full\nScreen Täis-\nekraan Use the browser's full screen (F11) Kasutage brauseri täisekraani (F11) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 75b1ffed5..edb6b4f9e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2095,6 +2095,11 @@ Leave out the date and time of comments and tracked changes when saving Omettre la date et l’heure des commentaires et des modifications suivies à l’enregistrement The document you are editing keeps the names and dates; only the saved file leaves them out. Le document en cours de modification garde les noms et les dates ; seul le fichier enregistré les omet. Privacy Settings Paramètres de confidentialité actuels + Paper Papier + Paper size: Taille du papier : + Custom size Taille personnalisée + More Paper Sizes… Autres tailles de papier… + The margins don't fit on a page this size; make the margins smaller first. Les marges ne tiennent pas sur une page de cette taille ; réduisez d’abord les marges. Full Screen Plein écran Full\nScreen Plein\nécran Use the browser's full screen (F11) Utilisez le plein écran du navigateur (F11) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b57039465..c7ebd6893 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2475,6 +2475,11 @@ Leave out the date and time of comments and tracked changes when saving Mentéskor a megjegyzések és a követett módosítások dátumának és idejének elhagyása The document you are editing keeps the names and dates; only the saved file leaves them out. A szerkesztett dokumentum megtartja a neveket és a dátumokat; csak a mentett fájlból maradnak ki. Privacy Settings Jelenlegi adatvédelmi beállítások + Paper Papír + Paper size: Papírméret: + Custom size Egyéni méret + More Paper Sizes… További papírméretek… + The margins don't fit on a page this size; make the margins smaller first. A margók nem férnek el ekkora oldalon; először csökkentse a margókat. Full Screen Teljes képernyő Full\nScreen Teljes\nképernyő Use the browser's full screen (F11) Használja a böngésző teljes képernyős módját (F11) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 46fad2c93..1d78bb46d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2462,6 +2462,11 @@ preview Title Esempio Leave out the date and time of comments and tracked changes when saving Ometti data e ora di commenti e revisioni al salvataggio The document you are editing keeps the names and dates; only the saved file leaves them out. Il documento che stai modificando conserva nomi e date; solo il file salvato li omette. Privacy Settings Impostazioni di privacy attuali + Paper Carta + Paper size: Dimensioni carta: + Custom size Dimensioni personalizzate + More Paper Sizes… Altre dimensioni carta… + The margins don't fit on a page this size; make the margins smaller first. I margini non entrano in una pagina di queste dimensioni; riduci prima i margini. Full Screen Schermo intero Full\nScreen Schermo\nintero Use the browser's full screen (F11) Usa lo schermo intero del browser (F11) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index daaddbfcb..8896a814b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2087,6 +2087,11 @@ Leave out the date and time of comments and tracked changes when saving 保存時にコメントと変更履歴の日時を含めない The document you are editing keeps the names and dates; only the saved file leaves them out. 編集中のドキュメントには名前と日時が残ります。保存したファイルにだけ含まれません。 Privacy Settings 現在のプライバシー設定 + Paper 用紙 + Paper size: 用紙サイズ: + Custom size ユーザー定義サイズ + More Paper Sizes… その他の用紙サイズ… + The margins don't fit on a page this size; make the margins smaller first. この用紙サイズには余白が収まりません。先に余白を小さくしてください。 Full Screen 全画面表示 Full\nScreen 全画面\n表示 Use the browser's full screen (F11) ブラウザーの全画面表示を使用してください (F11) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4be2c5507..4c6e4f088 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2471,6 +2471,11 @@ preview Title Tytuł Leave out the date and time of comments and tracked changes when saving Pomiń datę i godzinę komentarzy i śledzonych zmian przy zapisywaniu The document you are editing keeps the names and dates; only the saved file leaves them out. Edytowany dokument zachowuje nazwiska i daty; pomija je tylko zapisany plik. Privacy Settings Bieżące ustawienia prywatności + Paper Papier + Paper size: Rozmiar papieru: + Custom size Rozmiar niestandardowy + More Paper Sizes… Więcej rozmiarów papieru… + The margins don't fit on a page this size; make the margins smaller first. Marginesy nie mieszczą się na stronie tego rozmiaru; najpierw zmniejsz marginesy. Full Screen Pełny ekran Full\nScreen Pełny\nekran Use the browser's full screen (F11) Użyj trybu pełnoekranowego przeglądarki (F11) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 86ff9aa02..1a0c9a4b7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2099,6 +2099,11 @@ Leave out the date and time of comments and tracked changes when saving Omitir a data e a hora dos comentários e das alterações controladas ao salvar The document you are editing keeps the names and dates; only the saved file leaves them out. O documento que você está editando mantém os nomes e as datas; só o arquivo salvo os omite. Privacy Settings Configurações de privacidade atuais + Paper Papel + Paper size: Tamanho do papel: + Custom size Tamanho personalizado + More Paper Sizes… Mais tamanhos de papel… + The margins don't fit on a page this size; make the margins smaller first. As margens não cabem em uma página deste tamanho; reduza as margens primeiro. Full Screen Tela inteira Full\nScreen Tela\ninteira Use the browser's full screen (F11) Use a tela inteira do navegador (F11) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 050f874cd..993b27c14 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2477,6 +2477,11 @@ preview Title Пример Leave out the date and time of comments and tracked changes when saving Не сохранять дату и время примечаний и отслеживаемых изменений The document you are editing keeps the names and dates; only the saved file leaves them out. Редактируемый документ сохраняет имена и даты; их не будет только в сохранённом файле. Privacy Settings Текущие параметры конфиденциальности + Paper Бумага + Paper size: Размер бумаги: + Custom size Другой размер + More Paper Sizes… Другие размеры бумаги… + The margins don't fit on a page this size; make the margins smaller first. Поля не помещаются на странице такого размера; сначала уменьшите поля. Full Screen Во весь экран Full\nScreen Во весь\nэкран Use the browser's full screen (F11) Используйте полноэкранный режим браузера (F11) diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index d9aca608c..211af3972 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2097,6 +2097,11 @@ Leave out the date and time of comments and tracked changes when saving Izostavi datum i vreme komentara i praćenih izmena pri čuvanju The document you are editing keeps the names and dates; only the saved file leaves them out. Dokument koji uređujete zadržava imena i datume; izostavlja ih samo sačuvana datoteka. Privacy Settings Trenutna podešavanja privatnosti + Paper Papir + Paper size: Veličina papira: + Custom size Prilagođena veličina + More Paper Sizes… Još veličina papira… + The margins don't fit on a page this size; make the margins smaller first. Margine ne staju na stranu ove veličine; prvo smanjite margine. Full Screen Ceo ekran Full\nScreen Ceo\nekran Use the browser's full screen (F11) Koristite prikaz preko celog ekrana u pregledaču (F11) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d5ce60a5a..b89e800a0 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2101,6 +2101,11 @@ Leave out the date and time of comments and tracked changes when saving Изостави датум и време коментара и праћених измена при чувању The document you are editing keeps the names and dates; only the saved file leaves them out. Документ који уређујете задржава имена и датуме; изоставља их само сачувана датотека. Privacy Settings Тренутна подешавања приватности + Paper Папир + Paper size: Величина папира: + Custom size Прилагођена величина + More Paper Sizes… Још величина папира… + The margins don't fit on a page this size; make the margins smaller first. Маргине не стају на страну ове величине; прво смањите маргине. Full Screen Цео екран Full\nScreen Цео\nекран Use the browser's full screen (F11) Користите приказ преко целог екрана у прегледачу (F11) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ab261e1f3..d63c09cee 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2476,6 +2476,11 @@ preview Title Örnek Leave out the date and time of comments and tracked changes when saving Kaydederken açıklamaların ve izlenen değişikliklerin tarih ve saatini dahil etme The document you are editing keeps the names and dates; only the saved file leaves them out. Düzenlediğiniz belge adları ve tarihleri korur; yalnızca kaydedilen dosya bunları içermez. Privacy Settings Geçerli gizlilik ayarları + Paper Kağıt + Paper size: Kağıt boyutu: + Custom size Özel boyut + More Paper Sizes… Diğer Kağıt Boyutları… + The margins don't fit on a page this size; make the margins smaller first. Kenar boşlukları bu boyuttaki bir sayfaya sığmıyor; önce kenar boşluklarını küçültün. Full Screen Tam ekran Full\nScreen Tam\nekran Use the browser's full screen (F11) Tarayıcının tam ekran modunu kullanın (F11) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b0c38d67c..20d4c5bed 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2099,6 +2099,11 @@ Leave out the date and time of comments and tracked changes when saving Не зберігати дату й час приміток і відстежуваних змін The document you are editing keeps the names and dates; only the saved file leaves them out. Документ, який ви редагуєте, зберігає імена й дати; їх не буде лише у збереженому файлі. Privacy Settings Поточні налаштування конфіденційності + Paper Папір + Paper size: Розмір паперу: + Custom size Власний розмір + More Paper Sizes… Інші розміри паперу… + The margins don't fit on a page this size; make the margins smaller first. Поля не вміщуються на сторінці такого розміру; спершу зменште поля. Full Screen На весь екран Full\nScreen На весь\nекран Use the browser's full screen (F11) Скористайтеся повноекранним режимом браузера (F11) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 71a057825..e1848ba3f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2517,3 +2517,10 @@ {name} {height} × {width} in {name} {height} × {width} in {name} {height} × {width} mm {name} {height} × {width} mm {width} × {height} mm {width} × {height} mm + +# Custom paper sizes (#1496) + Custom size Kích thước tùy chỉnh + More Paper Sizes… Thêm khổ giấy… + Paper Giấy + Paper size: Khổ giấy: + The margins don't fit on a page this size; make the margins smaller first. Lề không vừa với trang có kích thước này; hãy thu nhỏ lề trước. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bc68ed579..7751d040b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2099,6 +2099,11 @@ Leave out the date and time of comments and tracked changes when saving 保存时不包含批注和修订的日期与时间 The document you are editing keeps the names and dates; only the saved file leaves them out. 正在编辑的文档保留姓名和日期;只有保存的文件中不包含它们。 Privacy Settings 当前隐私设置 + Paper 纸张 + Paper size: 纸张大小: + Custom size 自定义大小 + More Paper Sizes… 其他纸张大小… + The margins don't fit on a page this size; make the margins smaller first. 页边距放不进此大小的页面;请先减小页边距。 Full Screen 全屏 Full\nScreen 全屏 Use the browser's full screen (F11) 请使用浏览器的全屏模式 (F11) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fde965f1b..e350e75db 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2088,6 +2088,11 @@ Leave out the date and time of comments and tracked changes when saving 儲存時不包含註解和追蹤修訂的日期與時間 The document you are editing keeps the names and dates; only the saved file leaves them out. 正在編輯的文件會保留名稱和日期;只有儲存的檔案不包含它們。 Privacy Settings 目前的隱私設定 + Paper 紙張 + Paper size: 紙張大小: + Custom size 自訂大小 + More Paper Sizes… 其他紙張大小… + The margins don't fit on a page this size; make the margins smaller first. 邊界放不進此大小的頁面;請先縮小邊界。 Full Screen 全螢幕 Full\nScreen 全螢幕 Use the browser's full screen (F11) 請使用瀏覽器的全螢幕模式 (F11) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index dae35aed8..6f180135d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -403,6 +403,22 @@ fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { }); } +/// Layout › Size: the named paper sizes, then More Paper Sizes… for a custom size in Page +/// Setup (#1433). +pub(crate) fn size_menu(ui: &mut Ui, app: &mut WordApp) { + for (n, w, h) in wordcraft_geom::PAPER_SIZES { + // Paper names are the industry's (Letter, A4, B5 (JIS)) and stay as they are; the + // envelopes say what they are. Sizes in the measurement unit, written the region's + // way (`21 cm × 29,7 cm`, #838). + let name = if n.starts_with("Envelope ") { tl!(n) } else { n }; + let (width, height) = (crate::options::length(*w), crate::options::length(*h)); + let label = crate::i18n::fmt(tl!("{paper} {width} × {height}"), &[("paper", name), ("width", &width), ("height", &height)]); + mi_text(ui, app, &label, "layout.size", json!({"name": n})); + } + ui.separator(); + mi(ui, app, crate::dialogs::PAPER_LABELS.more, "ui.dialog", json!({"name": "pageSetup"})); +} + pub(crate) fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) { mi_text(ui, app, tl!(label), id, params); } @@ -1286,17 +1302,7 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Portrait", "layout.orientation", json!({"value": "portrait"})); mi(ui, app, "Landscape", "layout.orientation", json!({"value": "landscape"})); }); - menu_button(ui, app, "size", Some("Size"), "Size", true, "layout.size", |ui, app| { - for (n, w, h) in wordcraft_geom::PAPER_SIZES { - // Paper names are the industry's (Letter, A4, B5 (JIS)) and stay as they are; the - // envelopes say what they are. Sizes in the measurement unit, written the region's - // way (`21 cm × 29,7 cm`, #838). - let name = if n.starts_with("Envelope ") { tl!(n) } else { n }; - let (width, height) = (crate::options::length(*w), crate::options::length(*h)); - let label = crate::i18n::fmt(tl!("{paper} {width} × {height}"), &[("paper", name), ("width", &width), ("height", &height)]); - mi_text(ui, app, &label, "layout.size", json!({"name": n})); - } - }); + menu_button(ui, app, "size", Some("Size"), "Size", true, "layout.size", size_menu); menu_button(ui, app, "columns", Some("Columns"), "Columns", true, "layout.columns", |ui, app| { mi(ui, app, "One", "layout.columns", json!({"count": 1})); mi(ui, app, "Two", "layout.columns", json!({"count": 2})); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 776e3828c..70ac8068e 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-14 · **Change:** trivial (full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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 @@ -17,8 +17,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | +| Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766) | | 68% | 4–6 | @@ -52,6 +52,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | +| 2026-10-14 | trivial | Page Setup: a Paper group with Paper size (the named sizes, the one the section has in either orientation, or Custom size) and Width and Height in the measurement unit; a wider-than-tall size turns the orientation to landscape; a size the margins don't fit keeps the dialog open with a message and applies nothing. Layout › Size ends with More Paper Sizes…, which opens it (#1433) | | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | | 2026-10-14 | trivial | File dialogs: the system Save As type list names each format, the document's own first (#1399) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | From a124d58e5b064441eabe83be4395e4f193f453dd Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:58:53 +0200 Subject: [PATCH 070/199] Dark page: highlights are dimmed so the text on them stays readable (#1252) (#1498) * #1252: dim highlights on a dark page so the text on them reads On a dark page (View > Switch Modes) highlight and character-shading fills were lightness-inverted like every other colour, which leaves saturated hues such as yellow bright under white text. They are now marked as text backgrounds (Draw::Fill text_bg) and dimmed toward black keeping their hue (RenderOptions::ink_bg; yellow -> ~#4a4a10), and text drawn on them (Draw::Glyphs on_text_bg) takes the lighter of its colour and its inverted colour (RenderOptions::ink_text), so black and Automatic text draw white with WCAG contrast >= 4.5 on all 16 highlight colours. Light page, print and PDF are unchanged. Co-Authored-By: Claude Opus 5.5 * #1252: text on a dimmed highlight always reaches 4.5:1; lines too Glyphs and lines now carry the text background they sit on (Draw::Glyphs/Draw::Line text_bg: Option) instead of a bool. On a dark page RenderOptions::ink_text takes the lighter of the colour and its inversion, and falls back to white when that is under 4.5:1 against the dimmed background (pure blue on a dim olive was 1.34:1). Underlines and strikethroughs on a highlight use the same rule. The dim constants are crate-private and documented as assuming the default dark paper grey. Co-Authored-By: Claude Opus 5.5 * #1252: docs: the 4.5:1 rule for text and lines on highlights Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/layout/src/display.rs | 65 +++++++++++--- crates/layout/src/tests.rs | 42 +++++++++ crates/pdf/src/lib.rs | 6 +- crates/render/src/lib.rs | 162 +++++++++++++++++++++++++++++++++-- docs/ui-parity.md | 4 +- 6 files changed, 259 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6fdcdba2d..f9e6e6443 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: custom page size, #1433; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: dark-page highlights, #1252; 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. @@ -154,6 +154,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | | 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either | +| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged | | 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 | | 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents | | 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents | @@ -321,6 +322,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | | 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) | +| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) | | 2026-10-13 | trivial | Progress log: full screen on F11 (#980) | | 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) | | 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) | diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index aa799bfda..2b16fa7b4 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -38,11 +38,17 @@ pub enum Draw { /// Byte range of `text` each glyph shows (one per glyph; a ligature's range covers all /// its characters). Empty when unknown: the PDF writer then guesses from the font. ranges: Vec>, + /// The text background (highlight or character shading) these glyphs are drawn on, if + /// any: a dark page dims it, so it picks the text colour against the dimmed colour. + text_bg: Option, }, Fill { rect: Rect, color: Rgb, alpha: f32, + /// A text background (highlight or character shading), which a dark page dims instead + /// of inverting so the text on it stays readable. Other fills (paragraph, cell, page) don't. + text_bg: bool, }, Line { x0: f32, @@ -53,6 +59,9 @@ pub enum Draw { color: Rgb, stroke: Stroke, alpha: f32, + /// The text background this line (an underline or strikethrough) is drawn on, as for + /// [`Draw::Glyphs`]; `None` for every other line. + text_bg: Option, }, Image { rect: Rect, @@ -506,7 +515,17 @@ fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: || (mark_rev && line.end == LineEnd::Para); if changed { let top = y + (line.top - first.top); - out.push(Draw::Line { x0: bx, y0: top, x1: bx, y1: top + line.height, width: 0.75, color, stroke: Stroke::Solid, alpha }); + out.push(Draw::Line { + x0: bx, + y0: top, + x1: bx, + y1: top + line.height, + width: 0.75, + color, + stroke: Stroke::Solid, + alpha, + text_bg: None, + }); } } } @@ -533,6 +552,7 @@ fn rule(x0: f32, y0: f32, x1: f32, y1: f32, b: &Border, alpha: f32) -> Draw { color: b.color.unwrap_or(Rgb::BLACK), stroke: border_stroke(b.style), alpha, + text_bg: None, } } @@ -554,7 +574,7 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu /// effects follow all the turns it is drawn inside. fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer: Spin, out: &mut Vec) { match it { - Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }), + Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }), Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)), Placed::Image { rect, media, crop, spin, .. } => { spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }], out) @@ -637,7 +657,12 @@ fn lines( } for (x0, x1) in pl.x_spans(li, a.max(line.start), b.min(line.stop)) { if x1 > x0 { - out.push(Draw::Fill { rect: Rect::new(x + x0, top, x1 - x0, line.height), color: Rgb(0xEF, 0xE3, 0xF7), alpha }); + out.push(Draw::Fill { + rect: Rect::new(x + x0, top, x1 - x0, line.height), + color: Rgb(0xEF, 0xE3, 0xF7), + alpha, + text_bg: false, + }); } } } @@ -651,7 +676,7 @@ fn lines( continue; } if let Some(h) = st.rc.highlight.or(st.rc.shading) { - out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha }); + out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha, text_bg: true }); } let b = st.rc.border; if let Some((ob, x0, x1)) = open.as_mut() @@ -689,6 +714,7 @@ fn lines( text: run.text.clone(), link: None, ranges: Vec::new(), + text_bg: None, }); } } @@ -708,6 +734,7 @@ fn lines( text: "-".into(), link: None, ranges: Vec::new(), + text_bg: None, }); } // Glyph runs grouped by style. @@ -809,6 +836,8 @@ fn lines( (None, Some(r)) => m.delete_color.unwrap_or_else(|| author(r)), _ => text_color(&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 }); if del_mark == Some(DeleteMark::Caret) { @@ -825,6 +854,7 @@ fn lines( color, stroke: Stroke::Solid, alpha, + text_bg, }); out.push(Draw::Line { x0: cx, @@ -835,6 +865,7 @@ fn lines( color, stroke: Stroke::Solid, alpha, + text_bg, }); } continue; @@ -865,6 +896,7 @@ fn lines( text, link: rc.link.clone(), ranges, + text_bg, }); } // Decorations across the run (underline skips trailing spaces of the line). Right-to-left @@ -900,17 +932,17 @@ fn lines( if a < x0 - 0.01 || b > x1 + 0.01 { continue; // drawn with its own span } - out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha }); + out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha, text_bg }); } } else { let w = if underline == Underline::Thick { thick * 2.0 } else { thick }; - out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha }); + out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha, text_bg }); } } if struck { let sy = base - st.shift - st.size * 0.28; let stroke = if double { Stroke::Double } else { Stroke::Solid }; - out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha }); + out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha, text_bg }); } } } @@ -934,6 +966,7 @@ fn lines( color, stroke: if *grammar { Stroke::Double } else { Stroke::Wave }, alpha, + text_bg: None, }); } } @@ -963,6 +996,7 @@ fn lines( text: String::new(), link: None, ranges: Vec::new(), + text_bg: None, }); } // Inline objects. @@ -1037,6 +1071,7 @@ fn lines( text: r.text.clone(), link: None, ranges, + text_bg: st.rc.highlight.or(st.rc.shading), }); } } @@ -1069,6 +1104,7 @@ fn lines( color: Rgb(0x60, 0x60, 0x60), stroke: Stroke::Dotted, alpha, + text_bg: None, }); out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: label, color: None }); } @@ -1159,7 +1195,7 @@ fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f3 let tx = (x0 + x1 - w) / 2.0; for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] { for y in [mid - 1.2, mid + 1.2] { - out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha }); + out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha, text_bg: None }); } } out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() }); @@ -1181,8 +1217,11 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: & link: None, // Equation text maps back through the font (no per-glyph ranges from the math layout). ranges: Vec::new(), + text_bg: None, }), - MItem::Rect { x: rx, y, w, h, color } => out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha }), + MItem::Rect { x: rx, y, w, h, color } => { + out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha, text_bg: false }) + } MItem::Line { x0, y0, x1, y1, width, color, dotted } => out.push(Draw::Line { x0: x + x0, y0: base - y0, @@ -1192,6 +1231,7 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: & color: *color, stroke: if *dotted { Stroke::Dotted } else { Stroke::Solid }, alpha, + text_bg: None, }), MItem::ScreenOnly(inner) => { if screen { @@ -1428,6 +1468,7 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> text: text.clone(), link: None, ranges: Vec::new(), + text_bg: None, }) } @@ -1575,13 +1616,13 @@ pub fn upright_in_vertical(c: char) -> bool { fn upright_east_asian(items: Vec) -> Vec { let mut out = Vec::with_capacity(items.len()); for d in items { - let Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } = d else { + let Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, text_bg } = d else { out.push(d); continue; }; let up = |k: usize| ranges.get(k).and_then(|r| text.get(r.clone())).and_then(|t| t.chars().next()).is_some_and(upright_in_vertical); if ranges.len() != glyphs.len() || !(0..glyphs.len()).any(up) { - out.push(Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges }); + out.push(Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, text_bg }); continue; } let piece = |k0: usize, k1: usize| -> Draw { @@ -1590,7 +1631,7 @@ fn upright_east_asian(items: Vec) -> Vec { let (b0, b1) = (rs.iter().map(|r| r.start).min().unwrap_or(0), rs.iter().map(|r| r.end).max().unwrap_or(0)); let t = text.get(b0..b1).unwrap_or("").to_string(); let rs = rs.iter().map(|r| r.start.saturating_sub(b0)..r.end.saturating_sub(b0)).collect(); - Draw::Glyphs { face, size, glyphs: gs, color, alpha, synth_bold, synth_italic, text: t, link: link.clone(), ranges: rs } + Draw::Glyphs { face, size, glyphs: gs, color, alpha, synth_bold, synth_italic, text: t, link: link.clone(), ranges: rs, text_bg } }; let mut k0 = 0; while k0 < glyphs.len() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7baaad370..0f2a5f830 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5397,6 +5397,48 @@ fn automatic_spacing_drops_between_list_items() { } } +/// #1252: highlight and character-shading fills are marked as text backgrounds (a dark page dims +/// them), and so is the text drawn on them; a paragraph's shading is an ordinary fill. +#[test] +fn highlight_fill_is_marked_as_a_text_background() { + use wordcraft_doc::props::Highlight; + let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Yellow), ..Default::default() }; + let mut p = wordcraft_doc::Paragraph::with_text("Marked", cp); + p.props.shading = Some(wordcraft_doc::Rgb(0xDD, 0xEE, 0xFF)); + let plain = wordcraft_doc::Paragraph::with_text("Plain", Default::default()); + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(p), wordcraft_doc::para_block(plain)]; + let l = lay(&d); + let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()); + let fills: Vec<(wordcraft_doc::Rgb, bool)> = + items.iter().filter_map(|i| if let display::Draw::Fill { color, text_bg, .. } = i { Some((*color, *text_bg)) } else { None }).collect(); + assert!(fills.contains(&(wordcraft_doc::Rgb(255, 255, 0), true)), "highlight is a text background: {fills:?}"); + assert!(fills.contains(&(wordcraft_doc::Rgb(0xDD, 0xEE, 0xFF), false)), "paragraph shading is not: {fills:?}"); + let yellow = Some(wordcraft_doc::Rgb(255, 255, 0)); + let glyphs: Vec<(String, Option)> = items + .iter() + .filter_map(|i| if let display::Draw::Glyphs { text, text_bg, .. } = i { Some((text.clone(), *text_bg)) } else { None }) + .collect(); + assert!(glyphs.iter().any(|(t, bg)| t.contains("Marked") && *bg == yellow), "{glyphs:?}"); + assert!(glyphs.iter().any(|(t, bg)| t.contains("Plain") && bg.is_none()), "{glyphs:?}"); +} + +/// #1252 review: an underline and strikethrough on a highlighted run carry the highlight, like +/// its glyphs; other lines (here a paragraph border) don't. +#[test] +fn underline_on_a_highlight_carries_the_text_background() { + use wordcraft_doc::props::{Highlight, Underline}; + let cp = + wordcraft_doc::CharProps { highlight: Some(Highlight::Teal), underline: Some(Underline::Single), strike: Some(true), ..Default::default() }; + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Marked", cp))]; + let l = lay(&d); + let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()); + let lines: Vec> = + items.iter().filter_map(|i| if let display::Draw::Line { text_bg, .. } = i { Some(*text_bg) } else { None }).collect(); + assert_eq!(lines, vec![Some(wordcraft_doc::Rgb(0, 128, 128)); 2], "underline and strikethrough"); +} + /// #1352 helpers: a body paragraph with a PAGE field after each piece of `pieces` (a piece may /// hold a page break), after `before` paragraphs that each start a new page. fn page_field_doc(before: usize, pieces: &[&str]) -> Document { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1a818f3c4..f1a14c09f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -787,13 +787,13 @@ impl Exporter<'_> { self.draw(s, d); } } - Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } => { + Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, .. } => { self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text, ranges); if let Some(l) = link { self.link_rect(face, *size, glyphs, l); } } - Draw::Fill { rect, color, alpha } => { + Draw::Fill { rect, color, alpha, .. } => { if let Some(p) = rect_path(rect) { s.set_stroke(None); s.set_fill(Some(fill(*color, *alpha))); @@ -801,7 +801,7 @@ impl Exporter<'_> { s.set_fill(None); } } - Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha), + Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha, .. } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha), Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha), Draw::Path { segs, fill: f, stroke, stroke_width } => { let mut pb = PathBuilder::new(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8092d632a..48127cd31 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -374,6 +374,50 @@ impl RenderOptions { let lift = |v: u8| (p + v as u32 * (255 - p) / 255).min(255) as u8; Rgb(lift(r), lift(g), lift(b)) } + + /// The on-screen colour for a text background (highlight, character shading). A dark page + /// dims it toward black, keeping its hue, instead of inverting it: inverting leaves bright + /// hues such as yellow bright, so the white text on them can't be read (#1252). Every + /// colour lands dark enough for white text (WCAG contrast ≥ 4.5); yellow turns a dim olive. + pub fn ink_bg(&self, c: Rgb) -> Rgb { + if !self.dark { + return c; + } + let f = |v: u8| (DARK_TEXT_BG_FLOOR as u32 + v as u32 * DARK_TEXT_BG_SPAN as u32 / 255).min(255) as u8; + Rgb(f(c.0), f(c.1), f(c.2)) + } + + /// The on-screen colour for text (or its underline) drawn on the text background `bg`, if + /// any. A dark page dims those backgrounds ([`RenderOptions::ink_bg`]), so the text takes the + /// lighter of its colour and its inverted colour (black, explicit or Automatic, turns white; + /// Automatic white on a dark highlight stays white), and white if that still falls short of + /// 4.5:1 against the dimmed background (pure blue or red on a dim olive). White always + /// reaches it: no dimmed background is lighter than #4A4A4A. + pub fn ink_text(&self, c: Rgb, bg: Option) -> Rgb { + let inked = self.ink(c); + let Some(bg) = bg.filter(|_| self.dark) else { return inked }; + let lighter = if c.luma() > inked.luma() { c } else { inked }; + if contrast(lighter, self.ink_bg(bg)) >= 4.5 { lighter } else { Rgb::WHITE } + } +} + +// The dim assumes the default dark paper grey (`DARK_PAPER`; the app never sets another +// `dark_paper`): the backgrounds stay close to it and under #4A4A4A, so white text on them +// reaches 4.5:1. Revisit both if the paper grey becomes configurable. +/// Darkest channel of a text background on a dark page ([`RenderOptions::ink_bg`]). +const DARK_TEXT_BG_FLOOR: u8 = 0x10; +/// How far a full channel lifts a dark page's text background above the floor. +const DARK_TEXT_BG_SPAN: u8 = 0x3A; + +/// WCAG 2 contrast ratio between two sRGB colours (1 to 21). +fn contrast(a: Rgb, b: Rgb) -> f32 { + let lin = |v: u8| { + let s = v as f32 / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + let lum = |c: Rgb| 0.2126 * lin(c.0) + 0.7152 * lin(c.1) + 0.0722 * lin(c.2); + let (la, lb) = (lum(a), lum(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) } /// Invert HSL lightness, keeping hue and saturation: shifting every channel by @@ -452,7 +496,7 @@ fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOpti } for it in items { let (color, alpha) = match it { - Draw::Fill { rect, color, alpha } if rect.contains(p) => (*color, *alpha), + Draw::Fill { rect, color, alpha, .. } if rect.contains(p) => (*color, *alpha), // The shape's own outline (an ellipse's corners are not inside it). Shape fills are // drawn opaque (the model has no fill transparency), so they cover what's beneath. Draw::Shape { rect, kind, fill: Some(fill), corner, .. } @@ -590,18 +634,18 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl draw(ctx, doc, d, view, visible, opts); } } - Draw::Fill { rect, color: c, alpha } => { + Draw::Fill { rect, color: c, alpha, text_bg } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); if !r.overlaps(*visible) { return; } ctx.set_transform(view); - ctx.set_paint(color(opts.ink(*c), *alpha)); + ctx.set_paint(color(if *text_bg { opts.ink_bg(*c) } else { opts.ink(*c) }, *alpha)); ctx.fill_rect(&r); } - Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha } => { + Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha, text_bg } => { ctx.set_transform(view); - ctx.set_paint(color(opts.ink(*c), *alpha)); + ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha)); let w = *width as f64; let mut st = kurbo::Stroke::new(w); match stroke { @@ -632,14 +676,14 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_stroke(st); line(ctx, 0.0); } - Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, .. } => { + Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, text_bg, .. } => { // Invisible glyphs (WordArt letters are drawn from their outlines) carry text only. if *alpha <= 0.0 { return; } let db = FontDb::global(); let k = *size as f64 / face.upem.max(1.0); - ctx.set_paint(color(opts.ink(*c), *alpha)); + ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha)); let skew = if *synth_italic { Affine::new([1.0, 0.0, -0.21, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; // Outline strokes, in font units: synthetic bold, then on-screen stem darkening (a // stroke grows the outline by half its width on each side). Darkening is skipped for @@ -1254,6 +1298,110 @@ mod tests { assert!(light > 30, "light {light}"); } + /// A document whose only text is a yellow-highlighted run. + fn yellow_highlight_doc() -> (Document, wordcraft_layout::DocLayout) { + let cp = wordcraft_doc::CharProps { highlight: Some(wordcraft_doc::props::Highlight::Yellow), size: Some(24.0), ..Default::default() }; + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + (d, l) + } + + /// #1252: on a dark page a highlight is drawn dimmed (keeping its hue) so the white text on it + /// reads; a light page is unchanged. + #[test] + fn dark_page_dims_a_highlight_raster() { + let (d, l) = yellow_highlight_doc(); + let items = page_display(&d, &l.pages[0], &DisplayOptions::default()); + let r = items + .iter() + .find_map(|i| match i { + Draw::Fill { rect, color, .. } if *color == Rgb(255, 255, 0) => Some(*rect), + _ => None, + }) + .unwrap(); + // Just inside the top-left corner of the highlight: above the capitals, so pure background. + let (px, py) = ((r.x + 1.5) as u32, (r.y + 1.5) as u32); + let light = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); + assert_eq!(light.pixel(px, py), [255, 255, 0, 255], "light page keeps bright yellow"); + let dark = render_page(&d, &l.pages[0], 1.0, &RenderOptions { dark: true, ..Default::default() }); + let [r8, g8, b8, _] = dark.pixel(px, py); + assert!(r8 < 0x60 && g8 < 0x60, "dark page highlight is dim, got {:?}", (r8, g8, b8)); + assert!(r8.abs_diff(g8) <= 4 && g8 > b8.saturating_add(0x20), "dark page highlight stays yellow in hue, got {:?}", (r8, g8, b8)); + } + + /// #1252: on a dark page every highlight colour is dimmed so the text drawn on it reads + /// (Automatic and explicit black text both draw white); a light page is the identity. + #[test] + fn dark_page_text_backgrounds_keep_text_readable() { + use wordcraft_doc::props::{Highlight, TextColor}; + use wordcraft_layout::display::text_color; + let dark = RenderOptions { dark: true, ..Default::default() }; + let light = RenderOptions::default(); + let colours: Vec = Highlight::ALL.iter().filter_map(|h| h.rgb()).collect(); + assert_eq!(colours.len(), 16); + for c in colours { + let bg = dark.ink_bg(c); + for (what, tc) in [("automatic", text_color(&TextColor::Auto, Some(c))), ("black", Rgb::BLACK)] { + let fg = dark.ink_text(tc, Some(c)); + assert_eq!(fg, Rgb::WHITE, "{what} text on {c:?} draws white"); + let k = contrast(fg, bg); + assert!(k >= 4.5, "{what} text on {c:?}: {fg:?} on {bg:?} has contrast {k:.2}"); + } + assert_eq!(light.ink_bg(c), c, "light page keeps {c:?}"); + assert_eq!(light.ink_text(c, Some(c)), c, "light page keeps text {c:?}"); + } + // Yellow turns a dim olive (~#4a4a10), still yellow in hue. + let Rgb(r, g, b) = dark.ink_bg(Rgb(255, 255, 0)); + assert!(r.abs_diff(0x4A) <= 8 && g.abs_diff(0x4A) <= 8 && b.abs_diff(0x10) <= 8, "yellow → {:?}", (r, g, b)); + assert!(r == g && g > b); + // Text off a highlight keeps the plain inversion. + assert_eq!(dark.ink_text(Rgb::WHITE, None), dark.ink(Rgb::WHITE)); + } + + /// #1252 review: explicit coloured text (pure blue, red, every highlight colour) on every + /// dimmed highlight still reaches 4.5:1 on a dark page. + #[test] + fn dark_page_coloured_text_on_highlights_reads() { + use wordcraft_doc::props::Highlight; + let dark = RenderOptions { dark: true, ..Default::default() }; + let colours: Vec = Highlight::ALL.iter().filter_map(|h| h.rgb()).collect(); + let texts: Vec = [Rgb(0, 0, 255), Rgb(255, 0, 0)].into_iter().chain(colours.iter().copied()).collect(); + for &h in &colours { + let bg = dark.ink_bg(h); + for &tc in &texts { + let fg = dark.ink_text(tc, Some(h)); + let k = contrast(fg, bg); + assert!(k >= 4.5, "text {tc:?} on {h:?}: {fg:?} on {bg:?} has contrast {k:.2}"); + } + } + } + + /// #1252 review: an Automatic underline on a dark highlight (black) draws light on a dark page, + /// like the text it underlines, not the dark grey plain inversion gives white. + #[test] + fn dark_page_underline_on_a_dark_highlight_reads() { + use wordcraft_doc::props::{Highlight, Underline}; + let cp = + wordcraft_doc::CharProps { highlight: Some(Highlight::Black), underline: Some(Underline::Thick), size: Some(24.0), ..Default::default() }; + let mut d = Document::from_text("x"); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let items = page_display(&d, &l.pages[0], &DisplayOptions::default()); + let (x0, x1, y, w) = items + .iter() + .find_map(|i| match i { + Draw::Line { x0, x1, y0, width, .. } => Some((*x0, *x1, *y0, *width)), + _ => None, + }) + .unwrap(); + assert!(w >= 1.5, "thick underline {w}"); + let (px, py) = (((x0 + x1) / 2.0) as u32, y.round() as u32); + let dark = render_page(&d, &l.pages[0], 4.0, &RenderOptions { dark: true, ..Default::default() }); + let [r8, g8, b8, _] = dark.pixel(px * 4, py * 4); + assert!(r8 > 0xC0 && g8 > 0xC0 && b8 > 0xC0, "underline draws light, got {:?}", (r8, g8, b8)); + } + /// One EMF record: type, size, payload (a multiple of 4 bytes). fn emf_rec(typ: u32, payload: &[u8]) -> Vec { let mut v = typ.to_le_bytes().to_vec(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 70ac8068e..3e7b01f17 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: dark page dims highlights, #1252; previously trivial: full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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 @@ -35,6 +35,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | 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 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 | @@ -53,6 +54,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | | 2026-10-14 | trivial | Page Setup: a Paper group with Paper size (the named sizes, the one the section has in either orientation, or Custom size) and Width and Height in the measurement unit; a wider-than-tall size turns the orientation to landscape; a size the margins don't fit keeps the dialog open with a message and applies nothing. Layout › Size ends with More Paper Sizes…, which opens it (#1433) | +| 2026-10-13 | trivial | Dark page (View › Switch Modes): highlights and character shading are dimmed toward black, keeping their hue (yellow turns a dim olive), instead of having their lightness inverted, which left yellow bright under white text; text on them draws light (white for black and Automatic), coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule. Light page, print and exports unchanged. Unlike Word, Automatic text stays white on the dark page (#1252) | | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | | 2026-10-14 | trivial | File dialogs: the system Save As type list names each format, the document's own first (#1399) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | From 7471c33fe03fb7ffb50dd1d38e434c84da6199e5 Mon Sep 17 00:00:00 2001 From: shlok2721-web Date: Wed, 14 Oct 2026 23:29:06 +0530 Subject: [PATCH 071/199] Fix mini toolbar positioning while scrolling (#1501) --- crates/ui-egui/src/canvas.rs | 15 ++--- crates/ui-egui/src/mini_toolbar.rs | 89 +++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ec3c8856..691f48337 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1094,19 +1094,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } else { Vec::new() }; - let mut first: Option = None; + let visible_clip = ui.clip_rect(); + let mut screen_rects: Vec = Vec::with_capacity(selected.len()); for (pi, r) in selected { if let Some(pr) = rects.get(pi) { let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale)); let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale)); painter.rect_filled(sr, 0.0, t.selection); - // Track the topmost rect: the mini toolbar anchors on the selection's first line. - if first.is_none_or(|f| sr.min.y < f.min.y) { - first = Some(sr); - } + screen_rects.push(sr); } } - app.canvas.mini_anchor = first; + // Anchor only to visible selection geometry. A selection can span pages, and its + // document-first line may be scrolled offscreen; using it would pin the toolbar to an + // old screen position unrelated to the visible selection. + app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(screen_rects, visible_clip); // Content controls: the frame of the one the caret is in (all of them in Design Mode). crate::content_controls::paint(app, &painter, &t, &layout, &rects, geo.scale); // Read Aloud: the sentence being spoken. @@ -1383,7 +1384,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if let Some(sel) = app.canvas.mini_anchor && app.ui.mini_toolbar { - crate::mini_toolbar::show(app, ui.ctx(), sel, area); + crate::mini_toolbar::show(app, ui.ctx(), sel, out.inner_rect); } if app.session.view.read_mode { // Fixed navigation chrome, independent of the horizontally moving columns. diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index e5efee6d3..8f3259051 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -10,6 +10,8 @@ use crate::{WordApp, widgets}; /// Vertical gap between the bar and the selection's first line. const VGAP: f32 = 8.0; +/// Least height of a selection rectangle (screen points) that counts as visible for anchoring. +const MIN_VISIBLE_H: f32 = 1.0; /// Distance from the selection's left edge at which the bar is centred. const ANCHOR_INSET: f32 = 4.0; /// Padding inside the bar. @@ -41,6 +43,31 @@ const COMBO_FONT_W: f32 = ROW2_W - (COMBO_SIZE_W + GAP + 2.0 * BTN_W + 3.0 * SPA pub const BAR_W: f32 = PAD_X * 2.0 + ROW2_W; pub const BAR_H: f32 = PAD_Y * 2.0 + ROW_H * 2.0 + ROW_GAP; +/// The part of the selection the bar anchors to: the topmost selection rectangle that is actually +/// visible inside `clip` (the scroll viewport), cut down to its visible portion. `None` when no +/// usable part of the selection is on screen. Rectangles that only touch the clip's edge, or are +/// not finite, are ignored. Pure geometry, unit tested without a window. +pub fn visible_anchor(selection_rects: impl IntoIterator, clip: Rect) -> Option { + if !clip.is_finite() || !clip.is_positive() { + return None; + } + let mut first: Option = None; + for sr in selection_rects { + if !sr.is_finite() || !sr.intersects(clip) { + continue; + } + let visible = sr.intersect(clip); + // A sliver on the clip's edge is not a visible anchor (and could be zero-sized). + if visible.width() <= 0.0 || visible.height() < MIN_VISIBLE_H { + continue; + } + if first.is_none_or(|f| visible.min.y < f.min.y) { + first = Some(visible); + } + } + first +} + /// Where the bar sits for a selection line and viewport; `None` for degenerate inputs (NaN /// rects must never reach egui). Pure geometry, unit tested without a window. pub fn anchor_rect(selection_top: f32, selection_left: f32, viewport: Rect) -> Option { @@ -50,9 +77,22 @@ pub fn anchor_rect(selection_top: f32, selection_left: f32, viewport: Rect) -> O // Horizontal: centre on the selection's start (a few points in), clamped inside the viewport. let cx = (selection_left + ANCHOR_INSET).clamp(viewport.left(), viewport.right()); let x = (cx - BAR_W / 2.0).clamp(viewport.left(), (viewport.right() - BAR_W).max(viewport.left())); - // Vertical: above the selection, unless there is no room, then below it. + // Vertical: prefer above, then below. Check both viewport edges: a selection can be + // partially scrolled out of view, so merely checking `above >= viewport.top()` is not enough. let above = selection_top - VGAP - BAR_H; - let y = if above >= viewport.top() { above } else { selection_top + VGAP }; + let below = selection_top + VGAP; + let above_fits = above >= viewport.top() && above + BAR_H <= viewport.bottom(); + let below_fits = below >= viewport.top() && below + BAR_H <= viewport.bottom(); + let y = if above_fits { + above + } else if below_fits { + below + } else { + // Neither side has room (or the supplied anchor is outside the viewport). Keep the bar + // inside the visible area whenever the viewport can accommodate its height. + let desired = if above < viewport.top() { below } else { above }; + desired.clamp(viewport.top(), (viewport.bottom() - BAR_H).max(viewport.top())) + }; Some(Rect::from_min_size(pos2(x, y), Vec2::new(BAR_W, BAR_H))) } @@ -293,6 +333,49 @@ mod tests { } } + #[test] + fn vertical_position_stays_inside_viewport_for_offscreen_anchor() { + let viewport = vp(); + for selection_top in [viewport.top() - 500.0, viewport.bottom() + 500.0] { + let r = anchor_rect(selection_top, viewport.center().x, viewport).unwrap_or(Rect::EVERYTHING); + assert!(r.min.y >= viewport.top() - 1e-3, "bar top {} is above viewport {}", r.min.y, viewport.top()); + assert!(r.max.y <= viewport.bottom() + 1e-3, "bar bottom {} exceeds viewport {}", r.max.y, viewport.bottom()); + } + } + + #[test] + fn anchor_skips_rects_scrolled_offscreen_and_clips_partial_ones() { + let clip = vp(); + // Page 1's line is above the viewport, page 2's line is inside it. + let above = Rect::from_min_size(pos2(100.0, clip.top() - 300.0), Vec2::new(200.0, 20.0)); + let inside = Rect::from_min_size(pos2(120.0, clip.top() + 50.0), Vec2::new(200.0, 20.0)); + assert_eq!(visible_anchor([above, inside], clip), Some(inside)); + // Partly scrolled off the top: anchored to the visible part, never above the viewport. + let partial = Rect::from_min_size(pos2(100.0, clip.top() - 10.0), Vec2::new(200.0, 20.0)); + let a = visible_anchor([partial], clip).unwrap(); + assert!((a.min.y - clip.top()).abs() < 1e-3 && (a.height() - 10.0).abs() < 1e-3); + // Entirely off either end, or only touching the edge: nothing to anchor to. + let below = Rect::from_min_size(pos2(100.0, clip.bottom() + 5.0), Vec2::new(200.0, 20.0)); + let touching = Rect::from_min_size(pos2(100.0, clip.top() - 20.0), Vec2::new(200.0, 20.0)); + assert_eq!(visible_anchor([above, below, touching], clip), None); + assert_eq!(visible_anchor([], clip), None); + // Input order does not matter: the topmost visible rectangle wins. + let lower = Rect::from_min_size(pos2(100.0, clip.top() + 200.0), Vec2::new(200.0, 20.0)); + assert_eq!(visible_anchor([lower, inside], clip), Some(inside)); + // Degenerate input is rejected, not propagated. + let nan = Rect::from_min_size(pos2(f32::NAN, 0.0), Vec2::new(10.0, 10.0)); + assert_eq!(visible_anchor([nan], clip), None); + } + + #[test] + fn bar_for_a_partly_hidden_selection_stays_clear_of_the_top_edge() { + let clip = vp(); + let partial = Rect::from_min_size(pos2(100.0, clip.top() - 40.0), Vec2::new(200.0, 20.0 + 30.0)); + let a = visible_anchor([partial], clip).unwrap(); + let bar = anchor_rect(a.min.y, a.min.x, clip).unwrap(); + assert!(bar.min.y >= clip.top() - 1e-3 && bar.max.y <= clip.bottom() + 1e-3); + } + #[test] fn degenerate_inputs_are_rejected_not_crashed() { assert!(anchor_rect(f32::NAN, 10.0, vp()).is_none()); @@ -332,6 +415,8 @@ mod tests { h.step(); } let [bar, used] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar is shown over a selection"); + let viewport = h.state().canvas.viewport.expect("the canvas exposes its actual scroll viewport"); + assert!(viewport.contains_rect(bar), "toolbar {:?} must stay inside canvas viewport {:?}", bar, viewport); let inner = bar.shrink2(Vec2::new(PAD_X, PAD_Y)); assert!( inner.expand(0.1).contains_rect(used), From 0a1f2ce7a84ab2e82ba4ef4a72fbdd27fb1d2416 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:59:25 +0200 Subject: [PATCH 072/199] Canvas: draw colour emoji on screen as the PDF does (#1269) (#1504) * #1269: WIP * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * #1269: smaller bitmaps are accepted; dark page keeps emoji colours (test) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/fonts/src/color.rs | 381 ++++++++++++++++++++ crates/fonts/src/lib.rs | 1 + crates/render/src/color_glyph.rs | 439 ++++++++++++++++++++++++ crates/render/src/lib.rs | 24 +- crates/render/src/tests_color_glyphs.rs | 76 ++++ docs/layout-parity.md | 5 +- 7 files changed, 925 insertions(+), 5 deletions(-) create mode 100644 crates/fonts/src/color.rs create mode 100644 crates/render/src/color_glyph.rs create mode 100644 crates/render/src/tests_color_glyphs.rs diff --git a/ROADMAP.md b/ROADMAP.md index f9e6e6443..7bc8451ca 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: dark-page highlights, #1252; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: colour emoji on screen, #1269; 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. @@ -169,6 +169,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again | | 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's | | 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) | +| 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -334,6 +335,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) | | 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) | | 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) | +| 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/fonts/src/color.rs b/crates/fonts/src/color.rs new file mode 100644 index 000000000..897f31c96 --- /dev/null +++ b/crates/fonts/src/color.rs @@ -0,0 +1,381 @@ +//! Colour glyphs (#1269): emoji and other glyphs drawn from a font's colour tables instead of a +//! plain outline. COLR v0/v1 paint graphs become a flat list of [`ColorOp`]s; bitmap strikes +//! (`sbix`, `CBDT`) become a [`ColorBitmap`] placed in font units. The renderer turns either into +//! fills or an image; nothing here depends on a rasteriser. +//! +//! Fonts are hostile input: every lookup is checked, the op list and bitmap sizes are capped, and +//! a paint graph that fails part way (skrifa rejects cycles and over-deep graphs) yields `None`, so +//! the caller falls back to the outline. + +use kurbo::{Affine, BezPath, Point, Rect}; +use skrifa::color::{Brush, ColorGlyphCollection, ColorPainter, ColorPalettes, ColorStop, Transform}; +use skrifa::raw::types::BoundingBox; +use skrifa::{GlyphId, Tag}; + +pub use skrifa::color::{CompositeMode, Extend}; + +use crate::FontFace; + +/// Upper bound on the ops one colour glyph may expand to. +pub const MAX_OPS: usize = 20_000; +/// Upper bound on a bitmap glyph's width and height in pixels. +pub const MAX_BITMAP_SIDE: u32 = 2048; + +/// A colour from the font's palette, or the text colour (palette index 0xFFFF). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum GlyphColor { + /// Straight (not premultiplied) RGBA. + Rgba([u8; 4]), + /// The run's text colour, with this alpha multiplier. + Foreground(f32), +} + +/// A gradient stop. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Stop { + pub offset: f32, + pub color: GlyphColor, +} + +/// What a [`ColorOp::Fill`] paints the current clip with. Coordinates are in the space set by the +/// enclosing transforms. +#[derive(Clone, Debug, PartialEq)] +pub enum ColorPaint { + Solid(GlyphColor), + Linear { + p0: Point, + p1: Point, + stops: Vec, + extend: Extend, + }, + Radial { + c0: Point, + r0: f32, + c1: Point, + r1: f32, + stops: Vec, + extend: Extend, + }, + /// Angles in degrees, clockwise, as skrifa reports them. + Sweep { + c0: Point, + start_angle: f32, + end_angle: f32, + stops: Vec, + extend: Extend, + }, +} + +/// One drawing step of a COLR glyph, in font units with y up. Pushes and pops are balanced. +#[derive(Clone, Debug, PartialEq)] +pub enum ColorOp { + /// Concatenate a transform onto the current one. + PushTransform(Affine), + PopTransform, + /// Intersect the clip with this path (in the current transform's space). + PushClip(BezPath), + PopClip, + /// Open a layer that composites with this mode when popped. + PushLayer(CompositeMode), + PopLayer, + /// Fill the current clip. + Fill(ColorPaint), +} + +/// The encoded pixels of a bitmap glyph. +#[derive(Clone, Debug, PartialEq)] +pub enum BitmapBytes { + Png(Vec), + /// Premultiplied BGRA rows, `width * height * 4` bytes. + Bgra(Vec), +} + +/// A bitmap glyph: its pixels and where they go, in font units with y down from the pen position +/// (the same space as [`FontFace::glyph_outline`]). +#[derive(Clone, Debug, PartialEq)] +pub struct ColorBitmap { + pub data: BitmapBytes, + pub width: u32, + pub height: u32, + pub rect: Rect, + /// The strike's pixels per em. + pub ppem: f32, +} + +/// A glyph drawn from the font's colour data. +#[derive(Clone, Debug, PartialEq)] +pub enum ColorGlyph { + Layers(Vec), + Bitmap(ColorBitmap), +} + +impl FontFace { + /// Does this face carry colour glyph tables (COLR, sbix or CBDT)? Cheap: a table lookup. + pub fn has_color_glyphs(&self) -> bool { + self.skrifa().is_some_and(|f| [b"COLR", b"sbix", b"CBDT"].iter().any(|t| f.table_data(Tag::new(t)).is_some())) + } + + /// Glyph `gid` drawn in colour: its COLR paint (preferred: it scales), else the bitmap from the + /// strike nearest `ppem` device pixels per em. None when the glyph has no usable colour data; + /// the caller then draws the outline. + pub fn color_glyph(&self, gid: u32, ppem: f32) -> Option { + let f = self.skrifa()?; + let glyph = GlyphId::new(gid); + if let Some(cg) = ColorGlyphCollection::new(&f).get(glyph) { + let palette: Vec<[u8; 4]> = + ColorPalettes::new(&f).get(0).map(|p| p.colors().iter().map(|c| [c.red, c.green, c.blue, c.alpha]).collect()).unwrap_or_default(); + let mut rec = Recorder::new(&palette, |g: GlyphId| { + let mut p = self.glyph_outline(g.to_u32()); + p.apply_affine(Affine::FLIP_Y); + p + }); + if cg.paint(self.location(), &mut rec).is_ok() + && let Some(ops) = rec.finish() + { + return Some(ColorGlyph::Layers(ops)); + } + } + let upem = self.units_per_em(); + let ppem = if ppem.is_finite() && ppem > 0.0 { ppem.min(4096.0) } else { 64.0 }; + let bg = skrifa::bitmap::BitmapStrikes::new(&f).glyph_for_size(skrifa::instance::Size::new(ppem), glyph)?; + bitmap_placement(&bg, upem).map(ColorGlyph::Bitmap) + } +} + +/// Where a bitmap glyph goes, in font units, y down from the pen position. None for an empty, +/// oversized or unsupported (monochrome mask) bitmap. +pub fn bitmap_placement(bg: &skrifa::bitmap::BitmapGlyph<'_>, upem: f64) -> Option { + let (w, h) = (bg.width, bg.height); + if w == 0 || h == 0 || w > MAX_BITMAP_SIDE || h > MAX_BITMAP_SIDE { + return None; + } + let data = match bg.data { + skrifa::bitmap::BitmapData::Png(b) => BitmapBytes::Png(b.to_vec()), + skrifa::bitmap::BitmapData::Bgra(b) => { + let n = (w as usize).checked_mul(h as usize)?.checked_mul(4)?; + BitmapBytes::Bgra(b.get(..n)?.to_vec()) + } + skrifa::bitmap::BitmapData::Mask(_) => return None, + }; + let (px, py) = (bg.ppem_x as f64, bg.ppem_y as f64); + if !(px.is_finite() && py.is_finite() && px > 0.0 && py > 0.0 && upem.is_finite() && upem > 0.0) { + return None; + } + // Font units per bitmap pixel. + let (sx, sy) = (upem / px, upem / py); + let (bw, bh) = (w as f64 * sx, h as f64 * sy); + let x0 = bg.bearing_x as f64 + bg.inner_bearing_x as f64 * sx; + // Top edge, y up. + let top = match bg.placement_origin { + skrifa::bitmap::Origin::TopLeft => bg.bearing_y as f64 + bg.inner_bearing_y as f64 * sy, + skrifa::bitmap::Origin::BottomLeft => bg.bearing_y as f64 + bg.inner_bearing_y as f64 * sy + bh, + }; + let rect = Rect::new(x0, -top, x0 + bw, -top + bh); + let finite = [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()); + finite.then_some(ColorBitmap { data, width: w, height: h, rect, ppem: bg.ppem_y }) +} + +/// A [`ColorPainter`] that records the paint calls as [`ColorOp`]s, resolving palette colours and +/// clip glyphs. Stops recording (and [`Recorder::finish`] gives None) past [`MAX_OPS`]. +pub struct Recorder<'a, F: Fn(GlyphId) -> BezPath> { + palette: &'a [[u8; 4]], + outline: F, + ops: Vec, + overflow: bool, +} + +impl<'a, F: Fn(GlyphId) -> BezPath> Recorder<'a, F> { + /// A recorder with palette `palette` (straight RGBA) and clip outlines (font units, y up) from + /// `outline`. + pub fn new(palette: &'a [[u8; 4]], outline: F) -> Self { + Recorder { palette, outline, ops: vec![], overflow: false } + } + + /// The recorded ops, None when the glyph was too big or its pushes and pops don't balance. + pub fn finish(self) -> Option> { + if self.overflow { + return None; + } + let mut depth = 0i64; + for op in &self.ops { + match op { + ColorOp::PushTransform(_) | ColorOp::PushClip(_) | ColorOp::PushLayer(_) => depth += 1, + ColorOp::PopTransform | ColorOp::PopClip | ColorOp::PopLayer => depth -= 1, + ColorOp::Fill(_) => {} + } + if depth < 0 { + return None; + } + } + (depth == 0).then_some(self.ops) + } + + fn push(&mut self, op: ColorOp) { + if self.ops.len() >= MAX_OPS { + self.overflow = true; + return; + } + self.ops.push(op); + } + + fn color(&self, palette_index: u16, alpha: f32) -> GlyphColor { + let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + if palette_index == 0xFFFF { + return GlyphColor::Foreground(alpha); + } + match self.palette.get(palette_index as usize) { + Some(&[r, g, b, a]) => GlyphColor::Rgba([r, g, b, (a as f32 * alpha).round().clamp(0.0, 255.0) as u8]), + None => GlyphColor::Rgba([0, 0, 0, 0]), + } + } + + fn stops(&self, stops: &[ColorStop]) -> Vec { + let mut v: Vec = + stops.iter().filter(|s| s.offset.is_finite()).map(|s| Stop { offset: s.offset, color: self.color(s.palette_index, s.alpha) }).collect(); + v.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + v + } +} + +fn pt(p: skrifa::raw::types::Point) -> Point { + Point::new(p.x as f64, p.y as f64) +} + +impl BezPath> ColorPainter for Recorder<'_, F> { + fn push_transform(&mut self, t: Transform) { + self.push(ColorOp::PushTransform(Affine::new([t.xx, t.yx, t.xy, t.yy, t.dx, t.dy].map(|v| v as f64)))); + } + fn pop_transform(&mut self) { + self.push(ColorOp::PopTransform); + } + fn push_clip_glyph(&mut self, glyph_id: GlyphId) { + let p = (self.outline)(glyph_id); + self.push(ColorOp::PushClip(p)); + } + fn push_clip_box(&mut self, b: BoundingBox) { + let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64); + self.push(ColorOp::PushClip(kurbo::Shape::to_path(&r, 0.1))); + } + fn pop_clip(&mut self) { + self.push(ColorOp::PopClip); + } + fn fill(&mut self, brush: Brush<'_>) { + let paint = match brush { + Brush::Solid { palette_index, alpha } => ColorPaint::Solid(self.color(palette_index, alpha)), + Brush::LinearGradient { p0, p1, color_stops, extend } => { + ColorPaint::Linear { p0: pt(p0), p1: pt(p1), stops: self.stops(color_stops), extend } + } + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => { + ColorPaint::Radial { c0: pt(c0), r0, c1: pt(c1), r1, stops: self.stops(color_stops), extend } + } + Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend } => { + ColorPaint::Sweep { c0: pt(c0), start_angle, end_angle, stops: self.stops(color_stops), extend } + } + }; + self.push(ColorOp::Fill(paint)); + } + fn push_layer(&mut self, mode: CompositeMode) { + self.push(ColorOp::PushLayer(mode)); + } + fn pop_layer(&mut self) { + self.push(ColorOp::PopLayer); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(_: GlyphId) -> BezPath { + kurbo::Shape::to_path(&Rect::new(0.0, 0.0, 10.0, 10.0), 0.1) + } + + #[test] + fn records_a_clipped_solid_fill_with_palette_colours() { + let palette = [[255, 0, 0, 255], [0, 0, 255, 128]]; + let mut r = Recorder::new(&palette, square); + r.fill_glyph(GlyphId::new(3), None, Brush::Solid { palette_index: 1, alpha: 0.5 }); + r.fill_glyph(GlyphId::new(3), None, Brush::Solid { palette_index: 0xFFFF, alpha: 1.0 }); + let ops = r.finish().unwrap(); + assert_eq!(ops.len(), 6); + assert!(matches!(&ops[0], ColorOp::PushClip(p) if !p.elements().is_empty())); + assert_eq!(ops[1], ColorOp::Fill(ColorPaint::Solid(GlyphColor::Rgba([0, 0, 255, 64])))); + assert_eq!(ops[2], ColorOp::PopClip); + assert_eq!(ops[4], ColorOp::Fill(ColorPaint::Solid(GlyphColor::Foreground(1.0)))); + } + + #[test] + fn hostile_palette_indices_and_stops_never_panic() { + let palette = [[1, 2, 3, 255]]; + let mut r = Recorder::new(&palette, square); + let stops = [ + ColorStop { offset: 1.0, palette_index: 9999, alpha: f32::NAN }, + ColorStop { offset: f32::NAN, palette_index: 0, alpha: 1.0 }, + ColorStop { offset: 0.0, palette_index: 0, alpha: 7.0 }, + ]; + r.fill(Brush::LinearGradient { p0: Default::default(), p1: Default::default(), color_stops: &stops, extend: Extend::Pad }); + let ops = r.finish().unwrap(); + let ColorOp::Fill(ColorPaint::Linear { stops, .. }) = &ops[0] else { panic!("{ops:?}") }; + assert_eq!(stops.len(), 2, "the NaN offset is dropped"); + assert_eq!(stops[0], Stop { offset: 0.0, color: GlyphColor::Rgba([1, 2, 3, 255]) }); + assert_eq!(stops[1].color, GlyphColor::Rgba([0, 0, 0, 0]), "an out-of-range index is transparent"); + } + + #[test] + fn unbalanced_or_oversized_paint_is_rejected() { + let mut r = Recorder::new(&[], square); + r.pop_clip(); + r.push_clip_glyph(GlyphId::new(1)); + assert!(r.finish().is_none()); + let mut r = Recorder::new(&[], square); + r.push_clip_glyph(GlyphId::new(1)); + assert!(r.finish().is_none()); + let mut r = Recorder::new(&[], square); + for _ in 0..MAX_OPS + 5 { + r.fill(Brush::Solid { palette_index: 0, alpha: 1.0 }); + } + assert!(r.finish().is_none()); + } + + fn bitmap(origin: skrifa::bitmap::Origin, data: skrifa::bitmap::BitmapData<'_>) -> skrifa::bitmap::BitmapGlyph<'_> { + skrifa::bitmap::BitmapGlyph { + data, + bearing_x: 0.0, + bearing_y: 0.0, + inner_bearing_x: 2.0, + inner_bearing_y: 100.0, + ppem_x: 100.0, + ppem_y: 100.0, + advance: None, + width: 120, + height: 110, + placement_origin: origin, + } + } + + /// A CBDT strike places the bitmap's top-left corner `inner_bearing` pixels from the pen; sbix + /// places its bottom-left corner. Both scale pixels to font units by upem / ppem. + #[test] + fn bitmap_glyphs_are_placed_in_font_units() { + let png = [0u8; 4]; + let b = bitmap_placement(&bitmap(skrifa::bitmap::Origin::TopLeft, skrifa::bitmap::BitmapData::Png(&png)), 1000.0).unwrap(); + assert_eq!(b.rect, Rect::new(20.0, -1000.0, 1220.0, 100.0)); + let b = bitmap_placement(&bitmap(skrifa::bitmap::Origin::BottomLeft, skrifa::bitmap::BitmapData::Png(&png)), 1000.0).unwrap(); + assert_eq!(b.rect, Rect::new(20.0, -2100.0, 1220.0, -1000.0)); + // Short BGRA data, a zero ppem and a monochrome mask are refused, not read past. + assert!(bitmap_placement(&bitmap(skrifa::bitmap::Origin::TopLeft, skrifa::bitmap::BitmapData::Bgra(&png)), 1000.0).is_none()); + let mut z = bitmap(skrifa::bitmap::Origin::TopLeft, skrifa::bitmap::BitmapData::Png(&png)); + z.ppem_y = 0.0; + assert!(bitmap_placement(&z, 1000.0).is_none()); + let mut big = bitmap(skrifa::bitmap::Origin::TopLeft, skrifa::bitmap::BitmapData::Png(&png)); + big.width = MAX_BITMAP_SIDE + 1; + assert!(bitmap_placement(&big, 1000.0).is_none()); + } + + #[test] + fn faces_without_colour_tables_have_no_colour_glyphs() { + let face = crate::word::resolve("Source Sans 3", false, false).face; + assert!(!face.has_color_glyphs()); + assert_eq!(face.color_glyph(face.glyph_for('A'), 32.0), None); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 0d04ac7aa..cddcdad4b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod color; mod fontdb; pub mod math; mod script; diff --git a/crates/render/src/color_glyph.rs b/crates/render/src/color_glyph.rs new file mode 100644 index 000000000..8b169d9b6 --- /dev/null +++ b/crates/render/src/color_glyph.rs @@ -0,0 +1,439 @@ +//! Colour glyphs (#1269): emoji and other glyphs whose font draws them from COLR paint graphs or +//! bitmap strikes (sbix, CBDT) instead of outlines. The fonts crate reads the colour data +//! ([`FontFace::color_glyph`]); this module decodes bitmaps, caches the result per glyph and size, +//! and turns it into vello fills. The colours come from the font: a dark page doesn't invert them, +//! the same as pictures. Only the font's "text colour" entries follow the run's colour. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use vello_cpu::kurbo::{self, Affine, Point, Rect, Shape}; +use vello_cpu::peniko; +use vello_cpu::{Pixmap, RenderContext}; +use wordcraft_fonts::FontFace; +use wordcraft_fonts::color::{BitmapBytes, ColorGlyph, ColorOp, ColorPaint, CompositeMode, Extend, GlyphColor, Stop}; + +/// A colour glyph ready to draw: COLR ops, or a decoded bitmap and where it goes (font units, y +/// down from the pen position). +pub(crate) enum Drawable { + Layers(Vec), + Bitmap { pixmap: Arc, rect: Rect }, +} + +impl Drawable { + fn bytes(&self) -> usize { + match self { + Drawable::Layers(ops) => ops.len().saturating_mul(64), + Drawable::Bitmap { pixmap, .. } => pixmap.width() as usize * pixmap.height() as usize * 4, + } + } +} + +/// Upper bounds on the colour-glyph cache: entries and approximate bytes. Past either it starts over. +const CACHE_ENTRIES: usize = 4096; +const CACHE_BYTES: usize = 64 * 1024 * 1024; + +type Key = (u32, u32, u32); + +#[derive(Default)] +struct Cache { + map: HashMap>>, + bytes: usize, +} + +fn cache() -> &'static Mutex { + static C: std::sync::OnceLock> = std::sync::OnceLock::new(); + C.get_or_init(|| Mutex::new(Cache::default())) +} + +/// The strike size asked for at `ppem` device pixels per em: the next power of two, so zooming +/// doesn't decode a bitmap per zoom step, and a strike at least as big as needed is preferred. +fn ppem_bucket(ppem: f64) -> u32 { + let p = if ppem.is_finite() { ppem.clamp(1.0, 4096.0) } else { 64.0 }; + (p.ceil() as u32).next_power_of_two() +} + +/// Glyph `gid` of `face` in colour at about `ppem` device pixels per em, None when it has no colour +/// data (draw its outline). `cached` keeps the result in the shared cache (keyed by face id); a face +/// read just for a preview passes false so nothing outlives it. +pub(crate) fn drawable(face: &FontFace, gid: u32, ppem: f64, cached: bool) -> Option> { + let bucket = ppem_bucket(ppem); + if !cached { + return build(face, gid, bucket).map(Arc::new); + } + let key = (face.id(), gid, bucket); + if let Some(v) = crate::lock(cache()).map.get(&key) { + return v.clone(); + } + let d = build(face, gid, bucket).map(Arc::new); + let size = d.as_ref().map_or(0, |d| d.bytes()).saturating_add(64); + let mut c = crate::lock(cache()); + if c.map.len() >= CACHE_ENTRIES || c.bytes.saturating_add(size) > CACHE_BYTES { + c.map.clear(); + c.bytes = 0; + } + if size <= CACHE_BYTES { + c.map.insert(key, d.clone()); + c.bytes = c.bytes.saturating_add(size); + } + d +} + +fn build(face: &FontFace, gid: u32, ppem: u32) -> Option { + match face.color_glyph(gid, ppem as f32)? { + ColorGlyph::Layers(ops) => Some(Drawable::Layers(ops)), + ColorGlyph::Bitmap(b) => { + let pixmap = match &b.data { + BitmapBytes::Png(png) => decode_png(png, b.width, b.height)?, + BitmapBytes::Bgra(bgra) => bgra_pixmap(bgra, b.width, b.height)?, + }; + Some(Drawable::Bitmap { pixmap: Arc::new(pixmap), rect: b.rect }) + } + } +} + +/// Decode a bitmap glyph's PNG, which may be at most the `w`×`h` its strike claims (and at most +/// [`MAX_BITMAP_SIDE`](wordcraft_fonts::color::MAX_BITMAP_SIDE) a side): a hostile font could claim +/// a small glyph and hand over a huge image, so the size is read from the header before decoding. +/// A smaller, non-empty image is drawn stretched to the glyph's box: CBDT-only fonts have no +/// outline to fall back to. +fn decode_png(png: &[u8], w: u32, h: u32) -> Option { + let side = wordcraft_fonts::color::MAX_BITMAP_SIDE; + if w == 0 || h == 0 || w > side || h > side { + return None; + } + let reader = || image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); + let fits = |(iw, ih): (u32, u32)| iw > 0 && ih > 0 && iw <= w && ih <= h; + if !fits(reader().into_dimensions().ok()?) { + return None; + } + let mut limits = image::Limits::default(); + limits.max_image_width = Some(w); + limits.max_image_height = Some(h); + limits.max_alloc = Some(w as u64 * h as u64 * 4 * 2 + 64 * 1024); + let mut r = reader(); + r.limits(limits); + let img = r.decode().ok()?.to_rgba8(); + let (iw, ih) = img.dimensions(); + if !fits((iw, ih)) { + return None; + } + crate::premul_pixmap(iw, ih, img.as_raw()) +} + +/// A pixmap from premultiplied BGRA rows. +fn bgra_pixmap(bgra: &[u8], w: u32, h: u32) -> Option { + if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { + return None; + } + let n = (w as usize).checked_mul(h as usize)?.checked_mul(4)?; + let data: Vec = bgra + .get(..n)? + .as_chunks::<4>() + .0 + .iter() + // Premultiplied channels can't exceed alpha; clamp in case the font says otherwise. + .map(|p| vello_cpu::color::PremulRgba8 { r: p[2].min(p[3]), g: p[1].min(p[3]), b: p[0].min(p[3]), a: p[3] }) + .collect(); + Some(Pixmap::from_parts(data, w as u16, h as u16)) +} + +/// Draw a colour glyph. `at` maps font units (y down, pen at the origin) to device pixels; `fg` is +/// the run's text colour for the font's "text colour" entries; `alpha` fades the whole glyph. +pub(crate) fn draw(ctx: &mut RenderContext, d: &Drawable, at: Affine, upem: f64, fg: peniko::Color, alpha: f32) { + if !usable(at) { + return; + } + let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + let faded = alpha < 1.0; + if faded { + ctx.push_opacity_layer(alpha); + } + match d { + Drawable::Bitmap { pixmap, rect } => { + let (pw, ph) = (pixmap.width().max(1) as f64, pixmap.height().max(1) as f64); + ctx.set_transform(at); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pixmap.clone()), + sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium), + }); + ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / pw, rect.height() / ph)); + ctx.fill_rect(rect); + ctx.reset_paint_transform(); + } + Drawable::Layers(ops) => draw_ops(ctx, ops, at * Affine::FLIP_Y, upem, fg), + } + if faded { + ctx.pop_layer(); + } +} + +/// Play COLR ops (font units, y up) under `root`. Fills cover a generous box around the em square, +/// clipped by the glyph's clips. Layers left open by a malformed list are closed at the end. +fn draw_ops(ctx: &mut RenderContext, ops: &[ColorOp], root: Affine, upem: f64, fg: peniko::Color) { + let upem = if upem.is_finite() && upem > 0.0 { upem } else { 1000.0 }; + let area = Rect::new(-upem, -upem, upem * 2.0, upem * 2.0); + let mut transforms = vec![root]; + let mut open = 0usize; + for op in ops { + let cur = transforms.last().copied().unwrap_or(root); + match op { + ColorOp::PushTransform(t) => transforms.push(cur * *t), + ColorOp::PopTransform => { + if transforms.len() > 1 { + transforms.pop(); + } + } + ColorOp::PushClip(path) => { + // A degenerate or non-finite transform clips everything away: push an empty clip so + // the matching pop still has a layer to close. + if usable(cur) { + ctx.set_transform(cur); + ctx.push_clip_layer(path); + } else { + ctx.set_transform(root); + ctx.push_clip_layer(&kurbo::BezPath::new()); + } + open += 1; + } + ColorOp::PushLayer(mode) => { + ctx.push_blend_layer(blend_mode(*mode)); + open += 1; + } + ColorOp::PopClip | ColorOp::PopLayer => { + if open > 0 { + ctx.pop_layer(); + open -= 1; + } + } + ColorOp::Fill(paint) => fill(ctx, paint, cur, root, area, fg), + } + } + for _ in 0..open { + ctx.pop_layer(); + } +} + +/// Fill `area` (in root space) with `paint` defined in `cur` space. +fn fill(ctx: &mut RenderContext, paint: &ColorPaint, cur: Affine, root: Affine, area: Rect, fg: peniko::Color) { + if !usable(cur) { + return; + } + let region = (cur.inverse() * root) * area.to_path(0.1); + let solid = |ctx: &mut RenderContext, c: GlyphColor| { + ctx.set_transform(cur); + ctx.set_paint(resolve(c, fg)); + ctx.fill_path(®ion); + }; + let (gradient, stops, extend, transform) = match paint { + ColorPaint::Solid(c) => return solid(ctx, *c), + ColorPaint::Linear { p0, p1, stops, extend } => { + if !finite(&[p0.x, p0.y, p1.x, p1.y]) { + return; + } + (peniko::Gradient::new_linear(*p0, *p1), stops, extend, cur) + } + ColorPaint::Radial { c0, r0, c1, r1, stops, extend } => { + if !finite(&[c0.x, c0.y, c1.x, c1.y, *r0 as f64, *r1 as f64]) || *r1 <= 0.0 { + if let Some(s) = stops.last() { + solid(ctx, s.color); + } + return; + } + (peniko::Gradient::new_two_point_radial(*c0, r0.max(0.0), *c1, *r1), stops, extend, cur) + } + ColorPaint::Sweep { c0, start_angle, end_angle, stops, extend } => { + if !finite(&[c0.x, c0.y, *start_angle as f64, *end_angle as f64]) { + return; + } + let mut end = *end_angle; + if *start_angle == end { + if *extend != Extend::Pad { + return; + } + // A sweep needs distinct angles. + end += 0.01; + } + // Peniko sweeps clockwise in y-down space; COLR's (as skrifa reports them) are in this + // y-up space, so sweep in a flipped frame. + let g = peniko::Gradient::new_sweep(Point::new(c0.x, -c0.y), start_angle.to_radians(), end.to_radians()); + (g, stops, extend, cur * Affine::FLIP_Y) + } + }; + match stops.as_slice() { + [] => {} + [only] => solid(ctx, only.color), + _ => { + let stops: Vec = stops.iter().map(|s: &Stop| (s.offset.clamp(-1e6, 1e6), resolve(s.color, fg)).into()).collect(); + let g = gradient.with_stops(stops.as_slice()).with_extend(peniko_extend(*extend)); + ctx.set_transform(transform); + ctx.set_paint(g); + let region = transform.inverse() * root * area.to_path(0.1); + ctx.fill_path(®ion); + } + } +} + +/// Can a transform be drawn under (finite and invertible)? +fn usable(t: Affine) -> bool { + t.as_coeffs().iter().all(|v| v.is_finite()) && t.determinant().abs() >= 1e-12 +} + +fn finite(v: &[f64]) -> bool { + v.iter().all(|x| x.is_finite() && x.abs() < 1e7) +} + +fn resolve(c: GlyphColor, fg: peniko::Color) -> peniko::Color { + match c { + GlyphColor::Rgba([r, g, b, a]) => peniko::Color::from_rgba8(r, g, b, a), + GlyphColor::Foreground(a) => fg.multiply_alpha(if a.is_finite() { a.clamp(0.0, 1.0) } else { 1.0 }), + } +} + +fn peniko_extend(e: Extend) -> peniko::Extend { + match e { + Extend::Repeat => peniko::Extend::Repeat, + Extend::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Pad, + } +} + +fn blend_mode(m: CompositeMode) -> peniko::BlendMode { + use peniko::{Compose as C, Mix as M}; + let (mix, compose) = match m { + CompositeMode::Clear => (M::Normal, C::Clear), + CompositeMode::Src => (M::Normal, C::Copy), + CompositeMode::Dest => (M::Normal, C::Dest), + CompositeMode::DestOver => (M::Normal, C::DestOver), + CompositeMode::SrcIn => (M::Normal, C::SrcIn), + CompositeMode::DestIn => (M::Normal, C::DestIn), + CompositeMode::SrcOut => (M::Normal, C::SrcOut), + CompositeMode::DestOut => (M::Normal, C::DestOut), + CompositeMode::SrcAtop => (M::Normal, C::SrcAtop), + CompositeMode::DestAtop => (M::Normal, C::DestAtop), + CompositeMode::Xor => (M::Normal, C::Xor), + CompositeMode::Plus => (M::Normal, C::Plus), + CompositeMode::Screen => (M::Screen, C::SrcOver), + CompositeMode::Overlay => (M::Overlay, C::SrcOver), + CompositeMode::Darken => (M::Darken, C::SrcOver), + CompositeMode::Lighten => (M::Lighten, C::SrcOver), + CompositeMode::ColorDodge => (M::ColorDodge, C::SrcOver), + CompositeMode::ColorBurn => (M::ColorBurn, C::SrcOver), + CompositeMode::HardLight => (M::HardLight, C::SrcOver), + CompositeMode::SoftLight => (M::SoftLight, C::SrcOver), + CompositeMode::Difference => (M::Difference, C::SrcOver), + CompositeMode::Exclusion => (M::Exclusion, C::SrcOver), + CompositeMode::Multiply => (M::Multiply, C::SrcOver), + CompositeMode::HslHue => (M::Hue, C::SrcOver), + CompositeMode::HslSaturation => (M::Saturation, C::SrcOver), + CompositeMode::HslColor => (M::Color, C::SrcOver), + CompositeMode::HslLuminosity => (M::Luminosity, C::SrcOver), + _ => (M::Normal, C::SrcOver), + }; + peniko::BlendMode::new(mix, compose) +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_fonts::color::{GlyphColor, Stop}; + + fn render(ops: &[ColorOp]) -> crate::Rendered { + let mut ctx = RenderContext::new(40, 40); + ctx.set_paint(peniko::Color::WHITE); + ctx.fill_rect(&Rect::new(0.0, 0.0, 40.0, 40.0)); + let d = Drawable::Layers(ops.to_vec()); + // 1000 upem → 20 px, pen at (10, 30). + draw(&mut ctx, &d, Affine::translate((10.0, 30.0)) * Affine::scale(0.02), 1000.0, peniko::Color::from_rgba8(0, 128, 0, 255), 1.0); + ctx.flush(); + let mut pixels = vec![0u8; 40 * 40 * 4]; + if let Some(pm) = vello_cpu::PixmapMut::new(40, 40, &mut pixels) { + ctx.render(pm, &mut vello_cpu::Resources::new()); + } + crate::Rendered { width: 40, height: 40, pixels } + } + + fn square() -> kurbo::BezPath { + Rect::new(0.0, 0.0, 1000.0, 1000.0).to_path(0.1) + } + + /// A clipped solid fill lands inside the glyph's em box above the baseline, in the palette + /// colour; the text-colour entry follows the run's colour. + #[test] + fn colr_ops_fill_the_clip_in_palette_colours() { + let red = GlyphColor::Rgba([255, 0, 0, 255]); + let img = render(&[ColorOp::PushClip(square()), ColorOp::Fill(ColorPaint::Solid(red)), ColorOp::PopClip]); + assert_eq!(img.pixel(20, 20), [255, 0, 0, 255], "inside the em box"); + assert_eq!(img.pixel(20, 35), [255, 255, 255, 255], "below the baseline"); + assert_eq!(img.pixel(5, 20), [255, 255, 255, 255], "left of the pen"); + let img = render(&[ColorOp::PushClip(square()), ColorOp::Fill(ColorPaint::Solid(GlyphColor::Foreground(1.0))), ColorOp::PopClip]); + assert_eq!(img.pixel(20, 20), [0, 128, 0, 255]); + } + + #[test] + fn colr_gradients_paint_and_hostile_ops_never_panic() { + let stops = + vec![Stop { offset: 0.0, color: GlyphColor::Rgba([255, 0, 0, 255]) }, Stop { offset: 1.0, color: GlyphColor::Rgba([0, 0, 255, 255]) }]; + let linear = ColorPaint::Linear { p0: Point::new(0.0, 0.0), p1: Point::new(1000.0, 0.0), stops: stops.clone(), extend: Extend::Pad }; + let img = render(&[ColorOp::PushClip(square()), ColorOp::Fill(linear), ColorOp::PopClip]); + let (l, r) = (img.pixel(11, 20), img.pixel(29, 20)); + assert!(l[0] > 200 && l[2] < 60, "red at the start: {l:?}"); + assert!(r[2] > 200 && r[0] < 60, "blue at the end: {r:?}"); + let c = Point::new(500.0, 500.0); + let hostile = [ + ColorOp::PopClip, + ColorOp::PopTransform, + ColorOp::PushTransform(Affine::scale(0.0)), + ColorOp::Fill(ColorPaint::Solid(GlyphColor::Foreground(f32::NAN))), + ColorOp::PopTransform, + ColorOp::PushTransform(Affine::new([f64::NAN; 6])), + ColorOp::Fill(ColorPaint::Radial { c0: c, r0: -5.0, c1: c, r1: f32::INFINITY, stops: stops.clone(), extend: Extend::Pad }), + ColorOp::PopTransform, + ColorOp::PushLayer(CompositeMode::Multiply), + ColorOp::Fill(ColorPaint::Sweep { c0: c, start_angle: 0.0, end_angle: 0.0, stops: stops.clone(), extend: Extend::Pad }), + ColorOp::Fill(ColorPaint::Sweep { c0: c, start_angle: 0.0, end_angle: 0.0, stops, extend: Extend::Repeat }), + ColorOp::Fill(ColorPaint::Linear { p0: c, p1: c, stops: vec![], extend: Extend::Pad }), + ColorOp::PushTransform(Affine::new([f64::NAN; 6])), + ColorOp::PushClip(square()), + ColorOp::Fill(ColorPaint::Solid(GlyphColor::Rgba([255, 0, 0, 255]))), + ColorOp::PopClip, + ColorOp::PopTransform, + ColorOp::PushTransform(Affine::scale(0.0)), + ColorOp::PushClip(square()), + ColorOp::PopClip, + ColorOp::PopTransform, + ColorOp::PushClip(square()), + ]; + let _ = render(&hostile); + } + + #[test] + fn bgra_bitmaps_are_swizzled_and_short_data_refused() { + let pm = bgra_pixmap(&[10, 20, 30, 255, 0, 0, 0, 0], 2, 1).unwrap(); + assert_eq!(pm.width(), 2); + assert!(bgra_pixmap(&[1, 2, 3], 1, 1).is_none()); + assert!(decode_png(b"not a png", 4, 4).is_none()); + } + + fn png(w: u32, h: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([200, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + /// A bitmap glyph's PNG may not be bigger than its strike claims: a bigger one is refused before + /// it is decoded, so a hostile font can't claim a tiny glyph and make us decode a huge image. A + /// smaller one is drawn (CBDT-only fonts have no outline to fall back to). + #[test] + fn bitmap_png_larger_than_its_claimed_size_is_refused() { + assert_eq!(decode_png(&png(4, 4), 4, 4).map(|p| (p.width(), p.height())), Some((4, 4))); + assert_eq!(decode_png(&png(2, 2), 4, 4).map(|p| (p.width(), p.height())), Some((2, 2)), "smaller than claimed"); + assert!(decode_png(&png(8, 8), 4, 4).is_none(), "wider and taller than claimed"); + assert!(decode_png(&png(8, 4), 4, 4).is_none(), "wider than claimed"); + assert!(decode_png(&png(4, 8), 4, 4).is_none(), "taller than claimed"); + let side = wordcraft_fonts::color::MAX_BITMAP_SIDE; + assert!(decode_png(&png(4, 4), side + 1, 4).is_none(), "past the cap"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 48127cd31..abf4e29df 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,7 @@ use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_display}; use wordcraft_layout::wordart::ArtPaint; use wordcraft_metafile::{Item, Picture, PlacedItem, Seg}; +mod color_glyph; pub mod icon_lib; /// Worker threads for rasterising (0 on the web, where there are no threads). @@ -545,10 +546,16 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, let ascent = if face.ascent.is_finite() { face.ascent.clamp(0.0, upem * 4.0) } else { upem * 0.8 }; let baseline = margin + ascent * k; let mut x = margin; + let colour = face.has_color_glyphs(); for g in wordcraft_fonts::shape(face, text, &[], |c| c) { - let o = face.glyph_outline(g.gid); - ctx.set_transform(view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k)); - ctx.fill_path(&o); + let at = view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k); + if colour && let Some(d) = color_glyph::drawable(face, g.gid, size * scale as f64, false) { + color_glyph::draw(&mut ctx, &d, at, upem, color(Rgb(0, 0, 0), 1.0), 1.0); + ctx.set_paint(color(Rgb(0, 0, 0), 1.0)); + } else { + ctx.set_transform(at); + ctx.fill_path(&face.glyph_outline(g.gid)); + } x += g.x_advance as f64 * k; if x > w as f64 { break; @@ -696,6 +703,9 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter }; ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join)); } + // Emoji and other colour glyphs (#1269) are drawn from the font's colour data. + let colour = face.has_color_glyphs(); + let ppem = *size as f64 * px_per_pt; for (gid, x, y) in glyphs { let (gx, gy) = (*x as f64, *y as f64); // Written so a NaN position is skipped too. @@ -703,6 +713,12 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl if !near { continue; } + if colour && let Some(d) = color_glyph::drawable(face, *gid, ppem, true) { + let at = view * Affine::translate((gx, gy)) * skew * Affine::scale(k); + color_glyph::draw(ctx, &d, at, face.upem, color(opts.ink(*c), 1.0), *alpha); + ctx.set_paint(color(opts.ink(*c), *alpha)); + continue; + } let o = db.outline(face, *gid); if o.elements().is_empty() { continue; @@ -1601,3 +1617,5 @@ mod tests { #[cfg(test)] mod cache_tests; +#[cfg(test)] +mod tests_color_glyphs; diff --git a/crates/render/src/tests_color_glyphs.rs b/crates/render/src/tests_color_glyphs.rs new file mode 100644 index 000000000..4b2f8b99c --- /dev/null +++ b/crates/render/src/tests_color_glyphs.rs @@ -0,0 +1,76 @@ +//! Colour glyphs (#1269): emoji from colour fonts (bitmap strikes, COLR) are drawn in colour. + +use super::*; +use wordcraft_layout::{LayoutCache, LayoutOptions, layout}; + +/// Colour emoji families the system fallback may pick. +const COLOR_EMOJI: &[&str] = &["Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"]; + +/// A strongly coloured (non-grey) pixel. +fn colourful(p: [u8; 4]) -> bool { + let (hi, lo) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2])); + hi - lo > 60 +} + +/// System-dependent: skips (passes) when no colour emoji font is installed. Each emoji glyph's box +/// on the rendered page holds coloured pixels. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn emoji_are_drawn_in_colour() { + let Some((img, boxes)) = emoji_page(&RenderOptions::default()) else { return }; + for (x0, y0, x1, y1) in boxes { + let n = (x0..x1).flat_map(|x| (y0..y1).map(move |y| (x, y))).filter(|(x, y)| colourful(img.pixel(*x, *y))).count(); + assert!(n > 20, "only {n} coloured pixels in emoji box {x0},{y0}..{x1},{y1}"); + } +} + +/// A dark page (View › Switch Modes) doesn't invert the emoji: they keep the font's colours, the +/// same as pictures. The pixels well inside each emoji (coloured with coloured neighbours, so the +/// paper doesn't show through) match the light page's. Skips like [`emoji_are_drawn_in_colour`]. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn emoji_keep_their_colours_on_a_dark_page() { + let Some((light, boxes)) = emoji_page(&RenderOptions::default()) else { return }; + let Some((dark, _)) = emoji_page(&RenderOptions { dark: true, ..Default::default() }) else { return }; + for (x0, y0, x1, y1) in boxes { + let inside = |x: u32, y: u32| (x - 1..=x + 1).all(|i| (y - 1..=y + 1).all(|j| colourful(light.pixel(i, j)))); + let mut n = 0; + for (x, y) in (x0 + 1..x1.saturating_sub(1)).flat_map(|x| (y0 + 1..y1.saturating_sub(1)).map(move |y| (x, y))) { + if !inside(x, y) { + continue; + } + let (l, d) = (light.pixel(x, y), dark.pixel(x, y)); + assert!(l.iter().zip(d).all(|(a, b)| a.abs_diff(b) <= 2), "emoji pixel {x},{y} changed on a dark page: {l:?} → {d:?}"); + n += 1; + } + assert!(n > 10, "only {n} pixels well inside emoji box {x0},{y0}..{x1},{y1}"); + } +} + +/// "Emoji test: 🧠 brain, ✅ check." rendered at 2x with `opts`, and each emoji glyph's box in +/// pixels; None (the test skips) when no colour emoji font is installed. +#[cfg(not(target_arch = "wasm32"))] +fn emoji_page(opts: &RenderOptions) -> Option<(Rendered, Vec<(u32, u32, u32, u32)>)> { + let has_colour_font = FontDb::global().fallback_for('🧠', u32::MAX).is_some_and(|f| COLOR_EMOJI.contains(&f.family.as_str())); + if !has_colour_font { + eprintln!("skipped: no colour emoji font installed"); + return None; + } + let d = Document::from_text("Emoji test: 🧠 brain, ✅ check."); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let page = &l.pages[0]; + let scale = 2.0; + let img = render_page(&d, page, scale, opts); + let mut boxes = vec![]; + for it in page_display(&d, page, &DisplayOptions::default()) { + if let Draw::Glyphs { face, size, glyphs, .. } = it + && COLOR_EMOJI.contains(&face.family.as_str()) + { + for (_, x, y) in glyphs { + boxes.push((((x * scale) as u32), ((y - size) * scale) as u32, ((x + size) * scale) as u32, (y * scale) as u32)); + } + } + } + assert_eq!(boxes.len(), 2, "both emoji use the colour emoji font"); + Some((img, boxes)) +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index c21628949..3fbf0db1a 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-13 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; 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-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: colour emoji drawn on screen, partial: CBDT verified, COLR and sbix not yet seen on real fonts, #1269; previously trivial: colour emoji drawn on screen, #1269; 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, @@ -24,6 +24,7 @@ font can't match Word's metrics. | Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 | | Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 | | Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner | +| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | | Symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | — | | Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 | @@ -86,6 +87,8 @@ font can't match Word's metrics. | 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | | 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page | | 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) | +| 2026-10-14 | trivial | Colour emoji row set to 🟡: CBDT verified on Noto Color Emoji; COLR v0/v1 and sbix not yet seen on a real font (#1269) | +| 2026-10-14 | trivial | Colour emoji and colour-font glyphs (COLR, sbix, CBDT) drawn on screen and in page renders (#1269) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | From b78adf2def2873e623b2502b8bc97240fdf1d5c5 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 19:59:43 +0200 Subject: [PATCH 073/199] .doc import: cell shading, column count and merged cells (#1380) (#1507) * #1380: WIP * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * #1380: nil raw shading keeps the style colour; no zero-cell rows Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/docbin/src/table.rs | 110 ++++++++++++--- crates/docbin/src/tests.rs | 266 +++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- 4 files changed, 360 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7bc8451ca..64f406267 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: colour emoji on screen, #1269; 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; 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. @@ -170,6 +170,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's | | 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) | | 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures | +| 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -336,6 +337,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) | | 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) | | 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) | +| 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs index e34161862..b44b4147c 100644 --- a/crates/docbin/src/table.rs +++ b/crates/docbin/src/table.rs @@ -28,6 +28,15 @@ const T_TABLE_BORDERS_80: u16 = 0xD605; const T_ROW_HEIGHT: u16 = 0x9407; const T_DEF_TABLE: u16 = 0xD608; const T_DEF_TABLE_SHD_80: u16 = 0xD609; +/// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62. +const T_DEF_TABLE_SHD: u16 = 0xD612; +const T_DEF_TABLE_SHD_2ND: u16 = 0xD616; +const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C; +/// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell +/// ranges, which Word 2007+ writes next to the above. +const T_DEF_TABLE_SHD_RAW: u16 = 0xD670; +const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671; +const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672; const T_CANT_SPLIT_90: u16 = 0x3403; const T_MERGE: u16 = 0x5624; const T_VERT_MERGE: u16 = 0xD62B; @@ -49,8 +58,10 @@ pub(crate) struct RowInfo { pub(crate) cell_mark: bool, /// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs). pub(crate) depth: u32, - /// Column widths (points) from `sprmTDefTable`. - pub(crate) grid: Vec, + /// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row, + /// so each row keeps its own; ascending in well-formed rows, the first edge usually 0 + /// (the row origin). Rows without a `sprmTDefTable` keep this empty. + pub(crate) edges: Vec, height: Option, height_rule: HeightRule, header: bool, @@ -62,7 +73,9 @@ pub(crate) struct RowInfo { merges: Vec<(u8, u8)>, /// Per-cell overrides keyed by (pre-merge) cell index. vmerge: Vec<(u8, VMerge)>, - shading: Vec<(u8, Rgb)>, + /// Later entries win: the `Shd` arrays override the `Shd80` fallback before them, and an + /// automatic `Shd` (`None`) clears it. + shading: Vec<(u8, Option)>, widths: Vec<(u8, f32)>, valign: Vec<(u8, VAlign)>, } @@ -74,6 +87,12 @@ impl RowInfo { pub(crate) fn height(&self) -> Option { self.height } + + /// The fill cell `i` ends up with (the last shading entry for it wins), for tests. + #[cfg(test)] + pub(crate) fn shading_of(&self, i: u8) -> Option { + self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s) + } } pub(crate) fn decode(papx: &[u8]) -> RowInfo { @@ -87,7 +106,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0, P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0, P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32, - T_DEF_TABLE => r.grid = def_table_grid(prl.operand), + T_DEF_TABLE => r.edges = def_table_edges(prl.operand), T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0, T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0, T_ROW_HEIGHT => { @@ -135,8 +154,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb) && let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[]) { - for c in *a..*b { - r.shading.push((c, rgb)); + for c in *a..(*b).min(MAX_COLS as u8) { + r.shading.push((c, Some(rgb))); } } } @@ -144,10 +163,29 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { let cells = prl.operand.get(1..).unwrap_or(&[]); for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() { if let Some(rgb) = shd80_rgb(b) { - r.shading.push((i as u8, rgb)); + r.shading.push((i as u8, Some(rgb))); } } } + T_DEF_TABLE_SHD | T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => { + // Each array covers its own cell range (0-21, 22-43, 44-62); extra entries + // are ignored so an over-long array can't override the next range. + let (first, lim) = match prl.op { + T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND => (22, 44), + T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => (44, MAX_COLS), + _ => (0, 22), + }; + let cells = prl.operand.get(1..).unwrap_or(&[]); + let raw = matches!(prl.op, T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_RAW_3RD); + for (i, b) in cells.as_chunks::<10>().0.iter().take(lim - first).enumerate() { + // In the Raw arrays a nil Shd (ipat 0xFFFF) means "as the table style + // shades it", so it leaves any earlier fill alone. + if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) { + continue; + } + r.shading.push(((first + i) as u8, shd_rgb(b))); + } + } T_CELL_WIDTH => { // cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth. if let [a, b, fts, w0, w1, ..] = prl.operand @@ -221,8 +259,8 @@ pub(crate) fn assemble(paras: Vec) -> Blocks { if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) { c.props.width = Some(*w); } - if let Some((_, s)) = info.shading.iter().find(|(k, _)| *k == i) { - c.props.shading = Some(*s); + 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) { c.props.valign = *v; @@ -295,6 +333,13 @@ pub(crate) fn assemble(paras: Vec) -> Blocks { if !is_table { if open { flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1); + grid.clear(); + // Table props are per-table (first row carrying the sprm wins within the + // table): reset the accumulator so one table's borders, align, indent and + // cell margins never leak into the next (the repro's first table wrote + // Word's default 108-twip gap, and every later table — including the + // change history with its own 40-twip gap — inherited 5.4 pt margins). + props = TableProps::default(); open = false; depth_seen = 0; } @@ -305,8 +350,9 @@ pub(crate) fn assemble(paras: Vec) -> Blocks { open = true; } depth_seen = depth_seen.max(info.depth); - if !info.grid.is_empty() && grid.is_empty() { - grid = info.grid.clone(); + if grid.is_empty() { + // Column widths from the table's first `sprmTDefTable`. + grid = info.edges.windows(2).take(MAX_COLS).map(|w| (w[1] - w[0]).max(0.0)).collect(); } if info.borders.is_some() && props.borders.is_none() { props.borders = info.borders; @@ -318,10 +364,25 @@ pub(crate) fn assemble(paras: Vec) -> Blocks { props.indent = info.indent; } if info.cell_mark { + // A row-end (TTP) mark terminates the row. Word writes rows as N content + // cells each ended by their own cell mark, followed by an empty row-end mark + // that carries the row's TDefTable (the 3GPP change history: 8 content marks + // + an empty TTP whose TDefTable defines itcMac = 8). When that mark ends no + // content of its own and the TDefTable already has a column per flushed cell, + // it is a pure row terminator, not the end of a phantom (N+1)th cell — such a + // cell has no column (width None) and shifts every real cell's span one grid + // column over. A TTP mark with content, or one short of the TDefTable's + // columns, still ends a real cell (rows without a TDefTable keep today's + // shape: the count is unknowable, so the mark always flushes). + let row_end_only = info.ttp && p.para.text.is_empty() && cell.is_empty() && !info.edges.is_empty() && cells.len() + 1 >= info.edges.len(); + // ...but a row-end mark with no cells before it still makes a (one-cell) row. + let row_end_only = row_end_only && !cells.is_empty(); if !p.para.text.is_empty() { cell.push(para_block(p.para)); } - flush_cell(&mut cells, &mut cell); + if !row_end_only { + flush_cell(&mut cells, &mut cell); + } if info.ttp { let row = build_row(std::mem::take(&mut cells), &info); rows.push(row); @@ -336,21 +397,23 @@ pub(crate) fn assemble(paras: Vec) -> Blocks { out } -/// Column widths (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then itcMac+1 -/// twip boundaries followed by the TC80s (skipped). -fn def_table_grid(operand: &[u8]) -> Vec { +/// Cell-edge positions (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then +/// itcMac+1 twip boundaries followed by the TC80s (skipped). Edges are returned as stored: +/// per-row, in operand order; sorting and capping happen when the table grid is assembled. +fn def_table_edges(operand: &[u8]) -> Vec { let itc = operand.get(2).copied().unwrap_or(0) as usize; - if itc == 0 || itc > MAX_COLS { + if itc == 0 { return Vec::new(); } - let mut edges = Vec::with_capacity(itc + 1); + // itcMac is a u8, so this allocation stays tiny; the loop stops at the operand's end. + let mut edges = Vec::with_capacity((itc + 1).min(256)); for i in 0..=itc { match operand.get(3 + i * 2..3 + i * 2 + 2) { Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0), None => break, } } - edges.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect() + edges } /// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top, @@ -381,7 +444,8 @@ fn brc80(b: &[u8]) -> Option { } /// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns -/// show the foreground; other patterns are approximated by their background. +/// show the foreground; clear (0) and the other patterns show the background (Word writes a +/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill. fn shd_rgb(b: &[u8]) -> Option { let cv = |b: &[u8]| -> Option { match b { @@ -392,19 +456,21 @@ fn shd_rgb(b: &[u8]) -> Option { }; let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?; match ipat { - 0 | 0xFFFF => None, + 0xFFFF => None, 1 => cv(b.get(0..4)?), _ => cv(b.get(4..8)?), } } -/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). +/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). As in +/// [`shd_rgb`], solid shows the foreground and clear (0) the background, which is how Word +/// 97 writes a plain fill; ico 0 is automatic (no fill). fn shd80_rgb(b: &[u8]) -> Option { let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]); let fore = v & 0x1F; let back = (v >> 5) & 0x1F; let ipat = v >> 10; - if ipat == 0 || (fore == 0x1F && back == 0x1F) { + if fore == 0x1F && back == 0x1F { return None; } fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 }) diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index acad98605..14e6f37ce 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1906,3 +1906,269 @@ fn section_marks_end_paragraphs() { let doc = read(&struct_doc(&spec)).expect("opens"); assert_eq!(body_texts(&doc), ["a\u{C}b"]); } + +// #1380: Word 2000+ tables. Word writes each row as its cell marks plus a separate row-end +// (TTP) mark, cell shading as 10-byte `Shd`s in `sprmTDefTableShd`/`sprmTDefTableShdRaw` (the +// `Shd80` palette copy in `sprmTDefTableShd80` is only a fallback), and a horizontally +// merged row as a row with fewer, wider cells in its own `sprmTDefTable`. + +/// A `sprmTDefTable` Prl with `edges` twip boundaries and no TC80s (cb counts the bytes +/// after it, plus one; Word may store fewer TC80s than cells). +fn t_def_edges(edges: &[i16]) -> Vec { + let mut body = vec![(edges.len() as u8).saturating_sub(1)]; + for e in edges { + body.extend_from_slice(&e.to_le_bytes()); + } + let mut v = (0xD608u16).to_le_bytes().to_vec(); + v.extend_from_slice(&((body.len() + 1) as u16).to_le_bytes()); + v.extend_from_slice(&body); + v +} + +/// A variable-length table sprm whose operand is cb then `body`. +fn sprm_cb(op: u16, body: &[u8]) -> Vec { + let mut v = op.to_le_bytes().to_vec(); + v.push(body.len() as u8); + v.extend_from_slice(body); + v +} + +/// `n` copies of a 10-byte `Shd`: cvFore auto, cvBack = `back`, ipat 0 (clear), the way +/// Word writes a plain cell fill. +fn shd_clear(back: [u8; 3], n: usize) -> Vec { + let one = [0, 0, 0, 0xFF, back[0], back[1], back[2], 0, 0, 0]; + one.repeat(n) +} + +/// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row. +fn word_table_doc(row1: Vec, row2: Vec, row3: Vec) -> wordcraft_doc::Document { + let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r"; + let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); + let ttp = |extra: Vec| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].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.clone()), // a..e cell marks + (10, 0, ttp(row1)), // row 1 end mark + (11, 0, in_tbl.clone()), + (21, 0, ttp(row2)), + (22, 0, in_tbl), + (29, 0, ttp(row3)), + (30, 0, Vec::new()), // tail + ], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + read(&struct_doc(&spec)).expect("opens") +} + +#[test] +fn word_table_row_end_mark_and_cell_shading() { + let edges5: [i16; 6] = [0, 1440, 2880, 4320, 5760, 7200]; + // Row 1: the Shd80 fallback first (icoBack 9 = navy, clear), then the exact fill, dark + // blue 1C3A70, in sprmTDefTableShd (0xD612) and sprmTDefTableShdRaw (0xD670), as Word + // 365 writes it. The exact colours must beat the palette colour. + let shd80: Vec = [0x20u8, 0x01].repeat(5); + let row1 = [ + t_def_edges(&edges5), + sprm_cb(0xD609, &shd80), + sprm_cb(0xD612, &shd_clear([0x1C, 0x3A, 0x70], 5)), + sprm_cb(0xD670, &shd_clear([0x1C, 0x3A, 0x70], 5)), + ] + .concat(); + // Row 2: a Shd80 yellow fill (icoBack 7) on every cell, then sprmTDefTableShd with light + // blue E8F0FA for cells 0-3 and an automatic Shd for cell 4, which clears its fill. + let shd80_yellow: Vec = [0xE0u8, 0x00].repeat(5); + let shd2 = [shd_clear([0xE8, 0xF0, 0xFA], 4), vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]].concat(); + let row2 = [t_def_edges(&edges5), sprm_cb(0xD609, &shd80_yellow), sprm_cb(0xD612, &shd2)].concat(); + // Row 3: one cell from the first edge to the last, filled FFF2CC. + let row3 = [t_def_edges(&[0, 7200]), sprm_cb(0xD612, &shd_clear([0xFF, 0xF2, 0xCC], 1))].concat(); + let doc = word_table_doc(row1, row2, row3); + let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table"); + assert_eq!(tbl.grid.len(), 5, "grid columns"); + assert_eq!(tbl.rows.len(), 3); + let counts: Vec = tbl.rows.iter().map(|r| r.cells.len()).collect(); + assert_eq!(counts, [5, 5, 1], "cells per row (the row-end mark is not a cell)"); + let shd = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.shading).collect::>(); + assert_eq!(shd(0), vec![Some(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour"); + let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA)); + assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill"); + assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill"); + let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::(); + assert_eq!(legend, "legend"); + let tail = doc.body.last().and_then(|b| b.as_para()).expect("tail para"); + assert_eq!(tail.text, "tail"); +} + +#[test] +fn word97_shd80_clear_fill_shows_its_background() { + // Word 97 writes only sprmTDefTableShd80: a plain fill is pattern clear (ipat 0) with + // the colour in icoBack. Cells: icoBack 7 (yellow), icoBack 16 (silver), auto (none), + // solid (ipat 1) with icoFore 2 (blue). + let shd80: Vec = [0xE0u16, 16 << 5, 0, (1 << 10) | 2].iter().flat_map(|v| v.to_le_bytes()).collect(); + let papx = sprm_cb(0xD609, &shd80); + let row = crate::table::decode(&papx); + assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(255, 255, 0))); + assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192))); + assert_eq!(row.shading_of(2), None); + assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255))); +} + +#[test] +fn shd_arrays_keep_to_their_cell_ranges() { + // sprmTDefTableShd and sprmTDefTableShdRaw cover cells 0-21; their 2nd and 3rd forms + // cover 22-43 and 44-62. An over-long first array must not reach cell 22. + let red = shd_clear([0xFF, 0, 0], 1); + let green = shd_clear([0, 0xFF, 0], 1); + let blue = shd_clear([0, 0, 0xFF], 1); + let over_long = shd_clear([0x11, 0x22, 0x33], 23); + for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] { + let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat(); + let row = crate::table::decode(&papx); + assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}"); + assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22"); + assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44"); + let papx = sprm_cb(first, &red); + assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0))); + } +} + +#[test] +fn hostile_table_shading_and_spans_are_safe() { + // SHD arrays longer than the row, odd lengths, empty ones; itcMac past 63; edges that + // run backwards or past the grid; merges past the row's end. + // (Kept small: the fixture's single PAPX page holds every row's grpprl.) + let long = shd_clear([1, 2, 3], 6); + let rows: Vec<(Vec, Vec, Vec)> = vec![ + ([sprm_cb(0xD612, &long), sprm_cb(0xD616, &long)].concat(), sprm_cb(0xD60C, &long), sprm_cb(0xD670, &long)), + (sprm_cb(0xD612, &[1, 2, 3, 4, 5, 6, 7]), sprm_cb(0xD670, &[]), sprm_cb(0xD672, &long[..13])), + ( + { + let mut v = (0xD608u16).to_le_bytes().to_vec(); + v.extend_from_slice(&[0xFF, 0x00, 200]); + v.extend_from_slice(&[0x10; 24]); + v + }, + t_def_edges(&[7200, 0, -5, 30000]), + t_def_edges(&[-32000, 32000]), + ), + (t_def_edges(&[0]), t_def_edges(&[]), [t_def_edges(&[0, 100, i16::MAX]), sprm_cb(0xD612, &long)].concat()), + (grpprl(&[(0x5624, &[0, 200])]), grpprl(&[(0x5624, &[200, 3])]), grpprl(&[(0x5624, &[1, 255])])), + ]; + for (r1, r2, r3) in rows { + let doc = word_table_doc(r1, r2, r3); + let text = doc.plain_text(wordcraft_doc::StoryRef::Body); + assert!(text.contains("legend") && text.contains("tail"), "{text:?}"); + if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) { + assert!(t.grid.len() <= 63); + for r in &t.rows { + assert!(r.cells.iter().all(|c| c.props.span <= 63)); + } + } + } + // A row that is only a row-end mark (no cell marks before it), with an out-of-range + // merge and a SHD array, then a normal row. + // The second variant's sprmTDefTable is truncated to a single edge (itcMac 3), which + // must not turn the lone row-end mark into a row with no cells. + let text = "\u{7}a\u{7}\u{7}tail\r"; + let ttp = |extra: Vec| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat(); + let one_edge = { + let mut v = (0xD608u16).to_le_bytes().to_vec(); + v.extend_from_slice(&4u16.to_le_bytes()); // cb: itcMac + one edge, plus one + v.extend_from_slice(&[3, 0, 0]); + v + }; + for lone in [[t_def_edges(&[0, 1440]), grpprl(&[(0x5624, &[0, 9])]), sprm_cb(0xD612, &long)].concat(), one_edge] { + let spec = one_section( + text, + Vec::new(), + vec![ + (0, 0, ttp(lone)), + (1, 0, grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])])), + (3, 0, ttp(t_def_edges(&[0, 1440]))), + (4, 0, Vec::new()), + ], + ); + let doc = read(&struct_doc(&spec)).expect("opens"); + let text = doc.plain_text(wordcraft_doc::StoryRef::Body); + assert!(text.contains('a') && text.contains("tail"), "{text:?}"); + for t in doc.body.iter().filter_map(|b| b.as_table()) { + assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows"); + } + } +} + +#[test] +fn nil_raw_shading_keeps_the_earlier_colour() { + // In sprmTDefTableShdRaw (and its 2nd/3rd forms) a nil Shd (ipat 0xFFFF) means "as the + // table style shades it", so it must not override sprmTDefTableShd; automatic clears. + let blue = shd_clear([0, 0, 0xFF], 1); + let red = shd_clear([0xFF, 0, 0], 1); + let nil = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF]; + let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]; + let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat(); + let row = crate::table::decode(&papx); + assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue"); + assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0))); + assert_eq!(row.shading_of(2), None, "automatic clears"); + for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] { + let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat(); + let cell = if first == 0xD616 { 22 } else { 44 }; + assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}"); + } +} + +#[test] +fn second_table_gets_its_own_grid() { + // #1380: a 5-column table, a paragraph, then a 3-column table. The second table's grid + // comes from its own rows, not the first table's, and its borders, alignment and indent + // are its own too (the first table's centred, bordered and indented). + let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}between\rx\u{7}y\u{7}z\u{7}\u{7}tail\r"; + let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); + let ttp = |edges: &[i16]| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(edges)].concat(); + let table1_props = { + let mut borders = (0xD605u16).to_le_bytes().to_vec(); + borders.push(0x18); + for _ in 0..6 { + borders.extend_from_slice(&[4, 1, 1, 0]); + } + [borders, grpprl(&[(0x5400, &1u16.to_le_bytes()), (0x9601, &720i16.to_le_bytes())])].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.clone()), + (10, 0, [ttp(&[0, 1440, 2880, 4320, 5760, 7200]), table1_props].concat()), + (11, 0, Vec::new()), // "between" + (19, 0, in_tbl), + (25, 0, ttp(&[0, 2000, 4000, 7200])), + (26, 0, Vec::new()), // tail + ], + 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 tables: Vec<_> = doc.body.iter().filter_map(|b| b.as_table()).collect(); + assert_eq!(tables.len(), 2); + assert_eq!(tables[0].grid.len(), 5); + assert_eq!(tables[1].grid, vec![100.0, 100.0, 160.0], "second table's own grid"); + assert_eq!(tables[1].rows[0].cells.iter().map(|c| c.span()).collect::>(), [1, 1, 1]); + assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some()); + assert_eq!(tables[1].props.borders, None, "borders reset"); + assert_eq!(tables[1].props.align, None, "alignment reset"); + assert_eq!(tables[1].props.indent, None, "indent reset"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cd4a4761e..0258d7495 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: rounded rectangles keep their own corner radius, #1476; previously trivial: RTF import keeps sections, #1378; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-14 · **Change:** trivial (HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -22,8 +22,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.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. 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` (56) | 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]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | +| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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 or encrypted files are dropped or refused. No writer | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, revisions, section export, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | @@ -172,6 +172,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-14 | trivial | HTML: `` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) | | 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) | | 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) | +| 2026-10-14 | trivial | `.doc` tables: cell shading read from every SHD record (`sprmTDefTableShd`/`ShdRaw` and their 2nd/3rd ranges for cells 22-62; Word 97 `Shd80` clear fills now show their background colour; exact colours win over the palette fallback), the row-end mark no longer becomes an extra empty column, and a table no longer inherits the previous table's grid, borders, alignment or indent (#1380). Horizontally merged rows (a row of fewer, wider cells) come with #1123 | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | From 0c7b4a72f2eb4209177ab9314e1efec1cbc5f334 Mon Sep 17 00:00:00 2001 From: dfgodvan Date: Wed, 14 Oct 2026 21:06:17 +0300 Subject: [PATCH 074/199] Layout: long tables keep one layout per cell and relayout without re-measuring (#1492) - Table autofit measured every cell paragraph at an unbounded width through the paragraph cache, so a long table held two layouts per cell. The cache now keeps only the measured narrowest/widest widths (`LayoutCache::measures`, same key and eviction as the layouts). - The cache key formatted each cell's table-style text formatting with Debug for every cell paragraph on every pass; the hash is now memoised per pass. - Update Fields added the hidden ListEntry style even without a generated list, which changed the layout environment and dropped the whole cache. The style is now added only when a list is rebuilt. Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/citations.rs | 45 ++++++++++---- crates/layout/src/lib.rs | 97 ++++++++++++++++++++++++++---- crates/layout/src/table.rs | 2 +- crates/layout/src/tests.rs | 23 +++++++ 4 files changed, 142 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index 54bebe535..68fc82a63 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -711,17 +711,6 @@ const ENTRY_STYLE: &str = "ListEntry"; /// Rebuild every generated list. pub fn update_generated(s: &mut Session) -> Result<(), CmdError> { - if s.doc.styles.get(ENTRY_STYLE).is_none() { - s.doc.styles.upsert(wordcraft_doc::Style { - id: ENTRY_STYLE.into(), - name: "List Entry".into(), - kind: wordcraft_doc::StyleKind::Paragraph, - based_on: Some("Normal".into()), - para: wordcraft_doc::ParaProps { space_after: Some(4.0), ..Default::default() }, - hidden: true, - ..Default::default() - }); - } let mut i = 0; while i < s.doc.body.len() { let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind); @@ -729,6 +718,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> { i += 1; continue; }; + add_entry_style(s); if kind.starts_with("INDEX") { super::index::rebuild(s, i, None)?; i += 1; @@ -757,6 +747,23 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> { Ok(()) } +/// Add the style generated list entries take, if the document doesn't have it yet. Only when a +/// list is there: a new style changes the formatting every paragraph is laid out with, so the +/// layout cache starts over (and the saved file would gain a style it doesn't use). +fn add_entry_style(s: &mut Session) { + if s.doc.styles.get(ENTRY_STYLE).is_none() { + s.doc.styles.upsert(wordcraft_doc::Style { + id: ENTRY_STYLE.into(), + name: "List Entry".into(), + kind: wordcraft_doc::StyleKind::Paragraph, + based_on: Some("Normal".into()), + para: wordcraft_doc::ParaProps { space_after: Some(4.0), ..Default::default() }, + hidden: true, + ..Default::default() + }); + } +} + fn page_of(s: &mut Session, pos: &Pos) -> u32 { let l = s.layout(); l.caret(pos).and_then(|c| l.pages.get(c.page)).map(|p| p.number).unwrap_or(1) @@ -982,6 +989,22 @@ mod tests { assert!(s.doc.plain_text(StoryRef::Body).contains("[1]")); } + /// Updating fields in a document without generated lists leaves its styles alone: a new + /// style would make the next layout start over from nothing. + #[test] + fn update_fields_adds_the_entry_style_only_with_a_list() { + let mut s = Session::new(wordcraft_doc::Document::from_text("plain text")); + s.run("select.all", &json!({})).unwrap(); + s.run("references.updateFields", &json!({})).unwrap(); + assert!(s.doc.styles.get(ENTRY_STYLE).is_none()); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.citation", &json!({"source": {"author": "Rivera, Alex", "title": "Shared Spaces", "year": "2021"}})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.bibliography", &json!({})).unwrap(); + assert!(s.doc.styles.get(ENTRY_STYLE).is_some()); + } + #[test] fn bibliography_round_trip_docx() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 07010614f..be8a7c797 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -478,6 +478,8 @@ struct Key { #[derive(Default)] pub struct LayoutCache { paras: HashMap>, + /// Narrowest and widest content of paragraphs measured for table autofit ([`Ctx::para_measured`]). + measures: HashMap, /// Outlines of pictures' opaque pixels (Tight and Through wrapping), by media key, the /// media's bytes (address and length) and crop. outlines: HashMap<(String, usize, usize, [u32; 4]), Option>>>, @@ -499,6 +501,7 @@ impl LayoutCache { } pub fn clear(&mut self) { self.paras.clear(); + self.measures.clear(); self.outlines.clear(); } } @@ -625,6 +628,9 @@ struct Ctx<'a> { /// Body blocks that are empty paragraphs carrying only a section break before a continuous /// section, in order: they take no room (see [`place_para`]). quiet_marks: Vec, + /// Cache-key hashes of the table-style text formatting cells were laid out with + /// ([`Ctx::table_key`]). + table_keys: Vec<(para::CellText, u64)>, } impl Ctx<'_> { @@ -705,6 +711,51 @@ impl Ctx<'_> { exclusions: &[para::Exclusion], label: Option<(String, Level)>, ) -> Arc { + let (key, eq_number) = self.para_key(p, width, table, exclusions, label.as_ref()); + self.cache.used.insert(key.clone()); + if let Some(pl) = self.cache.paras.get(&key) { + self.cache.hits += 1; + return pl.clone(); + } + self.cache.misses += 1; + let pl = Arc::new(para::layout_para(p, &self.para_env(width, label, table, exclusions, eq_number))); + self.cache.paras.insert(key, pl.clone()); + pl + } + + /// What `measure` reads from `p` laid out as [`Ctx::para_labelled`] would, remembering only + /// the result: a table's columns measure every cell once at an unbounded width, and keeping + /// those layouts would double what a long table holds (and walk them all again on each pass). + fn para_measured( + &mut self, + p: &Paragraph, + width: f32, + table: Option<¶::CellText>, + label: Option<(String, Level)>, + measure: impl FnOnce(&ParaLayout) -> (f32, f32), + ) -> (f32, f32) { + let (key, eq_number) = self.para_key(p, width, table, &[], label.as_ref()); + self.cache.used.insert(key.clone()); + if let Some(m) = self.cache.measures.get(&key) { + self.cache.hits += 1; + return *m; + } + self.cache.misses += 1; + let m = measure(¶::layout_para(p, &self.para_env(width, label, table, &[], eq_number))); + self.cache.measures.insert(key, m); + m + } + + /// The cache key of `p` laid out with these inputs, and its first automatic equation number + /// (advancing the count past its equations). + fn para_key( + &mut self, + p: &Paragraph, + width: f32, + table: Option<¶::CellText>, + exclusions: &[para::Exclusion], + label: Option<&(String, Level)>, + ) -> (Key, u32) { let page = if has_page_fields(p) { Some(self.fields.page_key()) } else { None }; if self.track_totals && !self.uses_totals && has_total_fields(p) { self.uses_totals = true; @@ -723,10 +774,10 @@ impl Ctx<'_> { let key = Key { rev: p.rev, width: width.to_bits(), - label: label.as_ref().map(|(t, l)| format!("{t}|{}|{}|{:?}|{:?}|{:?}|{:?}", l.indent, l.hanging, l.suffix, l.align, l.tab, l.chr)), + label: label.map(|(t, l)| format!("{t}|{}|{}|{:?}|{:?}|{:?}|{:?}", l.indent, l.hanging, l.suffix, l.align, l.tab, l.chr)), page_from: if page.is_some() && !self.fields.page_from.is_empty() { hash_of(&self.fields.page_from) } else { 0 }, page, - table: table.map(|t| hash_of(&format!("{t:?}"))).unwrap_or(0), + table: table.map(|t| self.table_key(t)).unwrap_or(0), notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 }, excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) }, eq: if eq_here > 0 { eq_number } else { 0 }, @@ -735,13 +786,33 @@ impl Ctx<'_> { fit: self.boxes.inside(), hide_deleted: self.hide_deleted, }; - self.cache.used.insert(key.clone()); - if let Some(pl) = self.cache.paras.get(&key) { - self.cache.hits += 1; - return pl.clone(); + (key, eq_number) + } + + /// The cache-key hash of a cell's table-style text formatting. Hashing it means formatting it + /// as text, which a long table would otherwise do for each of its paragraphs on every pass; a + /// document's tables only give their text a few different formats. + fn table_key(&mut self, t: ¶::CellText) -> u64 { + if let Some((_, h)) = self.table_keys.iter().find(|(c, _)| c == t) { + return *h; } - self.cache.misses += 1; - let env = para::ParaEnv { + let h = hash_of(&format!("{t:?}")); + if self.table_keys.len() >= 64 { + self.table_keys.clear(); + } + self.table_keys.push((t.clone(), h)); + h + } + + fn para_env<'b>( + &'b self, + width: f32, + label: Option<(String, Level)>, + table: Option<&'b para::CellText>, + exclusions: &'b [para::Exclusion], + eq_number: u32, + ) -> para::ParaEnv<'b> { + para::ParaEnv { doc: self.doc, width, label, @@ -757,10 +828,7 @@ impl Ctx<'_> { read_line_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing } else { None }, grid: self.grid, fit_objects: self.boxes.inside(), - }; - let pl = Arc::new(para::layout_para(p, &env)); - self.cache.paras.insert(key, pl.clone()); - pl + } } } @@ -1786,6 +1854,7 @@ fn layout_pass( let env = env_hash(doc, opts); if env != cache.env { cache.paras.clear(); + cache.measures.clear(); cache.env = env; } cache.used.clear(); @@ -1821,6 +1890,7 @@ fn layout_pass( uses_totals: false, track_totals: true, quiet_marks: Vec::new(), + table_keys: Vec::new(), }; let sections = doc.sections(); let web = matches!(opts.view, ViewMode::Web | ViewMode::Draft | ViewMode::Outline); @@ -2077,9 +2147,10 @@ fn layout_pass( } } // Evict paragraph layouts that weren't used this pass if the cache grew large. - if ctx.cache.paras.len() > 4 * ctx.cache.used.len().max(1024) { + if ctx.cache.paras.len() + ctx.cache.measures.len() > 4 * ctx.cache.used.len().max(1024) { let used = std::mem::take(&mut ctx.cache.used); ctx.cache.paras.retain(|k, _| used.contains(k)); + ctx.cache.measures.retain(|k, _| used.contains(k)); ctx.cache.used = used; } let mut index: HashMap<(StoryRef, Path), Vec<(usize, usize)>> = HashMap::new(); diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 2a3df74c8..3a6b84a4e 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -1090,7 +1090,7 @@ fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth let (l, h) = match &**b { Block::Para(p) => { let label = list_level(ctx, p).map(|l| (String::new(), l)); - para_content(&ctx.para_labelled(p, UNBOUNDED, text, &[], label)) + ctx.para_measured(p, UNBOUNDED, text, label, para_content) } // A nested table: its fixed width, else its columns' but at least its preferred width // (its own, else its grid's when it has preferred widths at all). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 0f2a5f830..844d6a72f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -870,6 +870,29 @@ fn cache_reuses_unchanged_paragraphs() { assert_eq!(c.misses, misses + 1); } +/// Autofit measures every cell at an unbounded width; the cache keeps what it measured, not +/// those layouts, so a long table holds one layout per cell paragraph. Laying the document out +/// again after an edit elsewhere measures nothing again. +#[test] +fn cache_keeps_one_layout_per_table_cell() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(4, 3, 468.0); + for (r, row) in t.rows.iter_mut().enumerate() { + for (k, cell) in row.cells.iter_mut().enumerate() { + cell.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("row {r} cell {k}"), Default::default()))]; + } + } + assert!(!t.props.fixed); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let mut c = LayoutCache::new(); + layout(&d, &mut c, &LayoutOptions::default()); + assert_eq!(c.len(), 2 + 12); + let misses = c.misses; + d.insert_text(&Pos::body(0, 0), "x", &Default::default()).unwrap(); + layout(&d, &mut c, &LayoutOptions::default()); + assert_eq!(c.misses, misses + 1); +} + #[test] fn selection_rects_cover_range() { let d = Document::from_text("Hello world\nSecond line"); From 3d2002b699356a1ab2ec169eb238aa16b8ee888b Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:06:29 +0200 Subject: [PATCH 075/199] Layout: a paragraph continued into the next column wraps around a shape there (#1374) (#1505) * #1374: a paragraph continued into the next column or page wraps around the shapes there Co-Authored-By: Claude Opus 5.5 * #1374: wrap relays keep equation numbers, need the same line top, skip far-off shapes Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/layout/src/lib.rs | 56 ++++++++++++ crates/layout/src/tests.rs | 172 +++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +- 4 files changed, 234 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 64f406267..d977233c8 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: continued paragraphs wrap around the next column's shapes, #1374; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: 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. @@ -166,6 +166,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) | | 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height | +| 2026-10-14 | A paragraph that runs on into the next column or onto the next page wraps around the shapes there (#1374): its continued lines are broken again for that column's wrap areas instead of running through a shape there (or around one left on the page before); the lines before, and the paragraph's own floating objects, stay where they were. Paragraphs with nothing new to wrap around are not laid out again | | 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again | | 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's | | 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) | @@ -334,6 +335,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: table row margins and splitting | | 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) | +| 2026-10-14 | trivial | Progress log: continued paragraphs wrap around the next column's shapes (#1374) | | 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) | | 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) | | 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) | diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index be8a7c797..bb2671b4c 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2812,6 +2812,33 @@ fn again(ctx: &mut Ctx, eq_start: u32, f: impl FnOnce(&mut Ctx) -> T) -> T { out } +/// How many times one body paragraph's lines are broken again for the wrap areas of the later +/// columns and pages it runs onto (#1374). +const MAX_WRAP_RELAYS: usize = 64; + +#[cfg(test)] +thread_local! { + /// How many wrap relays (#1374) the layouts on this thread made: tests check none happen + /// without something new to wrap around. + static WRAP_RELAYS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The wrap areas for a paragraph laid out against `rel` (relative to its top) whose lines from the +/// one at `t` (relative to its top) on go into the column starting at `x0`, that line at `y` on +/// the page, which has the wrap areas `excl` (#1374). The areas the lines before were broken +/// around end at `t`; the column's start there (one reaching above `t` with an outline keeps its +/// whole outline, so it may change a line before, and the paragraph is then not broken again). +/// Areas well clear of the column (outside `-width..2 * width`, room for negative indents) can't +/// touch its lines and are left out. +fn continued_exclusions(rel: &[para::Exclusion], excl: &[WrapArea], x0: f32, y: f32, t: f32, width: f32) -> Vec { + let before = rel.iter().filter(|e| e.top < t).map(|e| para::Exclusion { bottom: e.bottom.min(t), ..e.clone() }); + let here = rel_exclusions(excl, x0, y - t) + .into_iter() + .filter(|e| e.bottom > t && e.right > -width && e.left < 2.0 * width) + .map(|e| if e.top < t && e.contour.is_none() { para::Exclusion { top: t, ..e } } else { e }); + before.chain(here).collect() +} + /// Run `f` with `map` as the field context's page map (`FieldCtx::page_from`), then take it back. fn with_page_map(ctx: &mut Ctx, map: &mut Vec<(usize, u32)>, f: impl FnOnce(&mut Ctx) -> T) -> T { ctx.fields.page_from = std::mem::take(map); @@ -2840,6 +2867,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: // page (bounded: a paragraph over hundreds of pages keeps the last number it got). let (mut laid_page, mut laid_width) = (pb.number, pb.col_w()); let mut page_relays = 0usize; + // How many times its lines were broken again for the wrap areas of a later column (#1374). + let mut wrap_relays = 0usize; // The anchor paragraph's top (above its space before) the floats were placed from. let mut anchor_y = pb.y; let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col); @@ -2924,6 +2953,33 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let mut after_on_page: Option = None; let mut l0 = 0; while l0 < n { + // The lines going into a new column (or page) were broken around the wrap areas where the + // paragraph started: break them again around this column's (the lines before stay as they + // were, and so do the paragraph's own floating objects), when they differ (#1374). + if l0 > 0 + && !pb.web + && wrap_relays < MAX_WRAP_RELAYS + && let Some((resume, t)) = pl.lines.get(l0).map(|l| (l.start, l.top)) + { + let here = continued_exclusions(&rel, &pb.excl, pb.col_x(), pb.y, t, laid_width); + if here != rel { + wrap_relays += 1; + ctx.fields.page = laid_page; + #[cfg(test)] + WRAP_RELAYS.with(|c| c.set(c.get() + 1)); + let relaid = + with_page_map(ctx, &mut page_map, |ctx| again(ctx, eq_start, |ctx| ctx.para_labelled(p, laid_width, None, &here, label.clone()))); + // Only if the rest still starts at the same line, at the same height (an outline + // reaching above it could move the lines before down, and the column's areas with them). + if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) + && relaid.lines.get(k).is_some_and(|l| (l.top - t).abs() < 0.01) + { + (pl, rel) = (relaid, here); + n = pl.lines.len(); + l0 = k; + } + } + } // Page fields in the lines going onto a new page show that page's number: lay the // paragraph out again with the fields from there on on this page (the lines before stay as // they were, so the line the rest starts with is still there), breaking the rest again for diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 844d6a72f..9a9ad40fa 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5806,3 +5806,175 @@ fn space_after_stays_clear_before_a_numbered_paragraph_without_a_label() { wordcraft_doc::resolve::AUTO_SPACING, ); } + +/// #1374 helpers: a page-anchored, square-wrapped rectangle `w`×`h` at (`x`, `y`) on the page. +fn page_shape(x: f32, y: f32, w: f32, h: f32) -> InlineObject { + use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; + let float = Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y, dist: 9.0, ..Default::default() }; + InlineObject::Shape { + kind: ShapeKind::Rectangle, + w, + h, + fill: None, + stroke: None, + stroke_width: 1.0, + float, + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + } +} + +/// #1374: a two-column (`cols`) document: paragraph 0 holds `shape` (if any), paragraph 1 is +/// `words` numbered words, long enough to run on from where it starts. +fn column_wrap_doc(cols: u32, shape: Option, words: usize) -> Document { + let text: String = (0..words).map(|i| format!("word{i} ")).collect(); + let mut d = Document::from_text(&format!("Anchor\n{}", text.trim_end())); + d.last_section.columns.count = cols; + if let Some(s) = shape { + d.insert_object(&Pos::body(0, 0), s, &Default::default()).unwrap(); + } + d +} + +/// #1374: every line of body paragraph `block` drawn on page `page` whose x is `col_x` (one column): +/// its page box (left, top, right, bottom). +fn line_boxes(l: &DocLayout, page: usize, block: u32, col_x: f32) -> Vec<(f32, f32, f32, f32)> { + let mut v = Vec::new(); + for it in &l.pages[page].items { + let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, x, y, .. } = it else { continue }; + if path.0 != [block] || (x - col_x).abs() > 1.0 { + continue; + } + let first = ¶.lines[*l0]; + for line in ¶.lines[*l0..*l1] { + let top = y + (line.top - first.top); + v.push((x + line.left, top, x + line.right, top + line.height)); + } + } + v +} + +/// #1374: no line in `lines` runs through `r` (grown by the shape's 9 pt wrap distance at the sides). +fn assert_clear_of(lines: &[(f32, f32, f32, f32)], r: Rect, what: &str) { + let mut beside = 0; + for &(l, t, rt, b) in lines { + if b <= r.y + 0.01 || t >= r.bottom() - 0.01 { + continue; + } + beside += 1; + assert!(l >= r.right() + 8.5 || rt <= r.x - 8.5, "{what}: line {l}..{rt} at {t}..{b} runs through the shape {r:?}"); + } + assert!(beside > 0, "{what}: some lines are beside the shape: {lines:?}"); +} + +/// #1374: a paragraph that runs on from column 1 into column 2 wraps around a shape in column 2. +#[test] +fn paragraph_continued_into_next_column_wraps_around_a_shape_there() { + let r = Rect::new(340.0, 300.0, 100.0, 100.0); + let d = column_wrap_doc(2, Some(page_shape(r.x, r.y, r.w, r.h)), 450); + let l = lay(&d); + let (_, ranges) = shown_and_ranges(&l, 1); + assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0)); + let col2 = l.pages[0].body.x + d.last_section.column_boxes()[1].0; + assert!(ranges.len() >= 2, "the paragraph runs into column 2: {ranges:?}"); + assert_clear_of(&line_boxes(&l, 0, 1, col2), r, "column 2"); +} + +/// #1374: a shape far to the left of the next column (in the page margin, beyond a column's width +/// from it) doesn't make the paragraph going into that column be laid out again. +#[test] +fn paragraph_continued_into_next_column_ignores_shapes_well_clear_of_it() { + let d = column_wrap_doc(2, Some(page_shape(0.0, 300.0, 20.0, 100.0)), 450); + WRAP_RELAYS.with(|c| c.set(0)); + let l = lay(&d); + assert_eq!(WRAP_RELAYS.with(|c| c.get()), 0, "no wrap relay"); + let (_, ranges) = shown_and_ranges(&l, 1); + assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0)); +} + +/// #1374: lines continued onto a new page are broken for that page: a shape on the page before +/// (reaching below its body) doesn't push them aside there. +#[test] +fn paragraph_continued_onto_next_page_is_broken_for_that_page() { + // On page 1 from 640 down past the page bottom, at the left of the text. + let d = column_wrap_doc(1, Some(page_shape(72.0, 640.0, 150.0, 150.0)), 900); + let l = lay(&d); + assert!(l.pages.len() >= 2, "{} pages", l.pages.len()); + let (_, ranges) = shown_and_ranges(&l, 1); + assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0)); + // Page 1's lines beside the shape keep clear of it; page 2 has nothing to wrap around. + let page1 = line_boxes(&l, 0, 1, 72.0); + assert_clear_of(&page1, Rect::new(72.0, 640.0, 150.0, 150.0), "page 1"); + let page2 = line_boxes(&l, 1, 1, 72.0); + assert!(!page2.is_empty()); + for (left, top, ..) in page2 { + assert!(left < 72.5, "page 2: the line at {top} starts at {left}, beside a shape that isn't on this page"); + } +} + +/// #1374: with nothing to wrap around, the lines going into the next column are those broken in +/// the first: the paragraph isn't laid out again (no wrap relay), and both pieces share one layout. +#[test] +fn paragraph_continued_into_next_column_without_shapes_is_not_laid_out_again() { + let d = column_wrap_doc(2, None, 450); + WRAP_RELAYS.with(|c| c.set(0)); + let l = lay(&d); + assert_eq!(WRAP_RELAYS.with(|c| c.get()), 0, "no wrap relay"); + let pieces: Vec<&Arc> = l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, para, .. } if path.0 == [1] => Some(para), + _ => None, + }) + .collect(); + assert_eq!(pieces.len(), 2, "one piece per column"); + assert!(Arc::ptr_eq(pieces[0], pieces[1]), "the second column reuses the first column's layout"); +} + +/// #1374: the paragraph's own floating object stays where it was placed (at its start, in column 1) +/// when its lines in column 2 are broken again around a shape there. +#[test] +fn paragraph_own_float_is_not_replaced_when_its_rest_wraps_in_the_next_column() { + use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; + let r = Rect::new(340.0, 300.0, 100.0, 100.0); + let mut d = column_wrap_doc(2, Some(page_shape(r.x, r.y, r.w, r.h)), 450); + let own = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: 9.0, ..Default::default() }; + let own = InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 80.0, + h: 60.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float: own, + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&Pos::body(1, 0), own, &Default::default()).unwrap(); + let l = lay(&d); + let owns: Vec<(usize, Rect)> = l + .pages + .iter() + .enumerate() + .flat_map(|(pi, p)| p.items.iter().map(move |it| (pi, it))) + .filter_map(|(pi, it)| match it { + Placed::Shape { rect, .. } if (rect.w - 80.0).abs() < 0.5 => Some((pi, *rect)), + _ => None, + }) + .collect(); + let col2 = l.pages[0].body.x + d.last_section.column_boxes()[1].0; + let boxes = para_boxes(&l); + assert_eq!(owns.len(), 1, "the own float is drawn once: {owns:?}"); + let (pi, own_rect) = owns[0]; + assert_eq!(pi, 0); + assert!((own_rect.x - 72.0).abs() < 0.5, "in column 1: {own_rect:?}"); + assert!((own_rect.y - boxes[1].2).abs() < 20.0, "at the paragraph's top {}: {own_rect:?}", boxes[1].2); + let (_, ranges) = shown_and_ranges(&l, 1); + assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0)); + assert_clear_of(&line_boxes(&l, 0, 1, col2), r, "column 2"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 3fbf0db1a..c86a812f4 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-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: colour emoji drawn on screen, partial: CBDT verified, COLR and sbix not yet seen on real fonts, #1269; previously trivial: colour emoji drawn on screen, #1269; 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-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a paragraph continued into the next column or page wraps around that column's shapes, #1374; previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; 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, @@ -46,7 +46,7 @@ font can't match Word's metrics. | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | -| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | +| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 | | Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | @@ -86,6 +86,7 @@ font can't match Word's metrics. | 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) | | 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) | | 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page | +| 2026-10-14 | trivial | A paragraph continued into the next column or page is broken again around the shapes there, not the ones where it started (#1374) | | 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) | | 2026-10-14 | trivial | Colour emoji row set to 🟡: CBDT verified on Noto Color Emoji; COLR v0/v1 and sbix not yet seen on a real font (#1269) | | 2026-10-14 | trivial | Colour emoji and colour-font glyphs (COLR, sbix, CBDT) drawn on screen and in page renders (#1269) | From b1e8ae07e76131f3fdd70bc72ca3b27f494e9a36 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:06:49 +0200 Subject: [PATCH 076/199] =?UTF-8?q?ODT=20import:=20keep=20each=20section?= =?UTF-8?q?=E2=80=99s=20page=20properties=20(#1379)=20(#1506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #1379: WIP * Co-Authored-By: Claude Opus 5.5 * #1379: master pages on table styles start sections too; spec tidy-ups Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/tests/odt_sections.rs | 74 ++++++ crates/formats/src/odt.rs | 397 ++++++++++++++++++++++++++-- docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 5 files changed, 461 insertions(+), 24 deletions(-) create mode 100644 crates/engine/tests/odt_sections.rs diff --git a/ROADMAP.md b/ROADMAP.md index d977233c8..30b098920 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-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: continued paragraphs wrap around the next column's shapes, #1374; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: 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-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; 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. @@ -152,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | +| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | | 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either | | 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged | @@ -323,6 +324,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) | | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | +| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | | 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) | | 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) | diff --git a/crates/engine/tests/odt_sections.rs b/crates/engine/tests/odt_sections.rs new file mode 100644 index 000000000..fd3a4fe0f --- /dev/null +++ b/crates/engine/tests/odt_sections.rs @@ -0,0 +1,74 @@ +//! #1379: an ODT file with several page styles and a column section converts to DOCX with one +//! `w:sectPr` per section. The ODT is original and synthetic, built from ODF 1.3: paragraphs +//! whose automatic styles name a master page (`style:master-page-name`), one restarting page +//! numbering (`style:page-number`), and a two-column `text:section`. +#![cfg(not(target_arch = "wasm32"))] + +use std::io::{Read, Write}; + +use wordcraft_engine::io; + +const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; + +fn odt() -> Vec { + let styles = format!( + r#" + + + + + + + "# + ); + let content = format!( + r#" + + + + + Annual summaryOpening text of the first part. + Volumes table + AB + Column text one.Column text two. + Closing wordsLast line. + "# + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", &content), ("styles.xml", &styles)] { + z.start_file(name, o).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn document_xml(docx: &[u8]) -> String { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap(); + let mut xml = String::new(); + zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + xml +} + +#[test] +fn odt_sections_convert_to_one_docx_section_each() { + let doc = io::open_bytes("sections.odt", &odt()).unwrap(); + assert_eq!(doc.sections().len(), 4); + let xml = document_xml(&io::save_bytes("sections.docx", &doc).unwrap()); + let secs: Vec<&str> = xml.split("").next().unwrap_or("")).collect(); + assert_eq!(secs.len(), 4, "{xml}"); + let orient: Vec = secs.iter().map(|s| s.contains(r#"w:orient="landscape""#)).collect(); + assert_eq!(orient, [false, true, true, false]); + // A4 both ways. + assert!(secs[0].contains(r#"w:w="11906""#) && secs[0].contains(r#"w:h="16838""#), "{}", secs[0]); + assert!(secs[1].contains(r#"w:w="16838""#) && secs[1].contains(r#"w:h="11906""#), "{}", secs[1]); + // Section 3: continuous, two columns with a line between; the others one column. + assert!(secs[2].contains(r#" = secs.iter().map(|s| s.contains(r#"w:start="1""#)).collect(); + assert_eq!(restarts, [false, true, false, true]); +} diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index ab288fc30..fca92ab99 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -16,7 +16,8 @@ use std::io::{Cursor, Read, Write}; use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; -use wordcraft_doc::{Align, Document, Rgb}; +use wordcraft_doc::section::{Columns, SectionStart}; +use wordcraft_doc::{Align, Document, Rgb, SectionProps}; use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, make_img, mime_of}; @@ -24,6 +25,8 @@ use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo const MAX_ENTRY: u64 = 256 << 20; /// Deepest element nesting handled in content.xml. const MAX_XML_DEPTH: usize = 512; +/// Most sections an import makes (page-style changes and column sections); later ones are ignored. +const MAX_SECTIONS: usize = 10_000; // --------------------------------------------------------------------------------------------- // Export @@ -633,6 +636,32 @@ struct StyleInfo { border_bottom: bool, /// `fo:background-color` of a table-cell style's `style:table-cell-properties`. cell_background: Option, + /// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a + /// page of that master page. Empty: explicitly none, which stops the lookup up the chain. + master_page: Option, + /// `style:page-number` of a paragraph or table style (applied only with a master page): `Some(Some(n))` restarts page numbering at n, + /// `Some(None)` ("auto") continues it. + page_number: Option>, + /// `style:columns` of a section style. + columns: Option, +} + +/// Columns of a `style:columns` element (ODF 1.3 §17.12): count and gap, clamped as the RTF and +/// DOCX readers do; equal widths only (`style:column` widths are not read). +fn columns_of(a: &Attrs) -> Columns { + let mut c = Columns::default(); + if let Some(n) = get(a, "fo:column-count").and_then(|v| v.trim().parse::().ok()) { + c.count = n.clamp(1, 45) as u32; + } + if let Some(g) = get(a, "fo:column-gap").and_then(length).filter(|v| *v >= 0.0) { + c.space = g.min(1584.0); + } + c +} + +/// A `style:column-sep` (ODF 1.3 §17.13) draws a line between columns unless its style is "none". +fn column_sep(a: &Attrs) -> bool { + get(a, "style:style") != Some("none") } /// Length → points. @@ -742,6 +771,7 @@ impl Styles { display: get(&a, "style:display-name").map(String::from), auto: in_auto && auto_section_is_auto, outline: get(&a, "style:default-outline-level").and_then(|v| v.parse::().ok()), + master_page: get(&a, "style:master-page-name").map(String::from), ..Default::default() }; cur = Some((name.to_string(), info)); @@ -788,6 +818,13 @@ impl Styles { } } } + "table-properties" => { + if let Some((_, s)) = &mut cur + && let Some(v) = get(&a, "style:page-number") + { + s.page_number = Some(v.trim().parse::().ok().map(|n| n.min(32_767))); + } + } "table-cell-properties" => { if let Some((_, s)) = &mut cur && let Some(c) = get(&a, "fo:background-color").and_then(model::parse_color) @@ -807,11 +844,26 @@ impl Styles { if get(&a, "fo:break-before") == Some("page") { s.page_break = true; } + if let Some(v) = get(&a, "style:page-number") { + s.page_number = Some(v.trim().parse::().ok().map(|n| n.min(32_767))); + } if get(&a, "fo:border-bottom").is_some_and(|b| b != "none") { s.border_bottom = true; } } } + "columns" if q.starts_with("style:") => { + if let Some((_, s)) = &mut cur { + s.columns = Some(columns_of(&a)); + } + } + "column-sep" => { + if let Some((_, s)) = &mut cur + && let Some(c) = &mut s.columns + { + c.separator = column_sep(&a); + } + } "list-style" => list = get(&a, "style:name").map(|n| (n.to_string(), vec![false; 10])), "list-level-style-number" | "list-level-style-bullet" | "list-level-style-image" => { if let Some((_, v)) = &mut list { @@ -870,6 +922,19 @@ impl Styles { f } + /// The page a paragraph style starts (`style:master-page-name` and `style:page-number`, + /// ODF 1.3 §16.2 and §17.6): the nearest master page and page-number setting in its chain. + fn page_start<'a>(&'a self, name: &'a str) -> (Option<&'a str>, Option>) { + let chain = self.chain(name); + let master = chain.iter().find_map(|(_, s)| s.master_page.as_deref()).filter(|m| !m.is_empty()); + (master, chain.iter().find_map(|(_, s)| s.page_number)) + } + + /// Columns of a section style (whole chain). + fn columns(&self, name: &str) -> Option { + self.chain(name).iter().find_map(|(_, s)| s.columns.clone()) + } + /// Background colour of a table-cell style (whole chain). fn cell_background(&self, name: &str) -> Option { self.chain(name).iter().find_map(|(_, s)| s.cell_background) @@ -945,6 +1010,16 @@ struct Body<'a> { depth: usize, frame: Option<(Option, Option, Option>, String)>, in_desc: bool, + /// The master pages' page setups. + pages: &'a Pages, + /// The current master page's setup (a column section's page, without its columns). + page: SectionProps, + /// The open section; where its blocks start in the body; how many sections have ended. + sec: SectionProps, + sec_from: usize, + sections: usize, + /// Element depth of the open columned `text:section`. + cols_at: Option, } impl Body<'_> { @@ -960,9 +1035,67 @@ impl Body<'_> { self.fmt.last().cloned().unwrap_or_default() } + /// Reading the body itself, not a table cell or a frame. + fn in_body(&self) -> bool { + self.containers.len() == 1 && self.tables.is_empty() && self.frame.is_none() + } + + /// End the open section before what comes next, which starts section `next`. An open section + /// with nothing in it yet takes `next`'s place instead. Only in the body, and at most + /// [`MAX_SECTIONS`]. True when what comes next follows a section break. + fn break_section(&mut self, next: SectionProps) -> bool { + if !self.in_body() { + return false; + } + let Some(body) = self.containers.first_mut() else { return false }; + if body.len() <= self.sec_from { + let restart = self.sec.page_num_start; + self.sec = next; + self.sec.page_num_start = self.sec.page_num_start.or(restart); + return self.sections > 0; + } + if self.sections + 1 >= MAX_SECTIONS { + return false; + } + // A section ends on a paragraph: after a table, an empty one (as Word writes it). + if !matches!(body.last(), Some(FBlock::Para(p)) if p.section.is_none()) { + body.push(FBlock::Para(Para::default())); + } + if let Some(FBlock::Para(p)) = body.last_mut() { + p.section = Some(Box::new(std::mem::replace(&mut self.sec, next))); + } + self.sec_from = body.len(); + self.sections += 1; + true + } + + /// A body paragraph or table of style `name` that names a master page starts a new page with + /// that page's setup and, when the style says so, page numbering restarted (ODF 1.3 applies + /// `style:page-number` only with a master page). The section that starts there, if any. + /// + /// Rare cases left simple: a master-page paragraph first inside a columned `text:section` + /// replaces that (still empty) column section, so the columns are lost; a columned section + /// nested in another is read as part of the outer one. + fn page_section(&mut self, name: &str) -> Option { + if !self.in_body() { + return None; + } + let (master, number) = self.styles.page_start(name); + let restart = number.flatten(); + let page = master.and_then(|m| self.pages.masters.get(m))?.clone(); + self.page = page.clone(); + Some(SectionProps { start: SectionStart::NextPage, page_num_start: restart, ..page }) + } + fn start_para(&mut self, style: Option<&str>, heading: Option) { self.end_para(); - let (mut kind, align, pb, rule, base) = style.map(|s| self.styles.para(s)).unwrap_or((Kind::Normal, None, false, false, Fmt::default())); + let (mut kind, align, mut pb, rule, base) = style.map(|s| self.styles.para(s)).unwrap_or((Kind::Normal, None, false, false, Fmt::default())); + // The section break starts the new page; no page break besides. + if let Some(next) = style.and_then(|s| self.page_section(s)) + && self.break_section(next) + { + pb = false; + } if let Some(h) = heading { kind = Kind::Heading(h.clamp(1, 6)); } @@ -1154,6 +1287,10 @@ impl Body<'_> { } "table" if q.starts_with("table:") => { self.end_para(); + // A body table whose style names a master page starts that page (ODF 1.3 §19.509). + if let Some(next) = get(a, "table:style-name").and_then(|s| self.page_section(s)) { + self.break_section(next); + } if self.tables.len() >= model::MAX_DEPTH { self.skip = 1; return; @@ -1202,6 +1339,18 @@ impl Body<'_> { } } "desc" | "title" if self.frame.is_some() => self.in_desc = true, + // A columned section (ODF 1.3 §5.4) is a continuous section with its columns. + "section" if q.starts_with("text:") => { + self.end_para(); + if self.cols_at.is_none() + && self.in_body() + && let Some(columns) = get(a, "text:style-name").and_then(|s| self.styles.columns(s)).filter(|c| c.count > 1) + { + let next = SectionProps { start: SectionStart::Continuous, page_num_start: None, columns, ..self.page.clone() }; + self.break_section(next); + self.cols_at = Some(self.depth); + } + } _ => {} } } @@ -1274,6 +1423,12 @@ impl Body<'_> { } } "desc" | "title" => self.in_desc = false, + "section" if q.starts_with("text:") && self.cols_at == Some(self.depth + 1) => { + self.end_para(); + self.cols_at = None; + let next = SectionProps { start: SectionStart::Continuous, page_num_start: None, ..self.page.clone() }; + self.break_section(next); + } "frame" => { if let Some((w, h, Some(data), alt)) = self.frame.take() && let Some(img) = make_img(data, w, h, alt.trim()) @@ -1330,6 +1485,11 @@ fn read_meta(xml: &[u8]) -> Meta { /// Parse an `.odt` package into the flow model. pub fn parse(bytes: &[u8]) -> Result { + parse_full(bytes).map(|(flow, _)| flow) +} + +/// [`parse`], with the last section's properties (the others are on the paragraphs that end them). +fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?; if let Some(mt) = read_entry(&mut z, "mimetype") { let mt = String::from_utf8_lossy(&mt); @@ -1339,9 +1499,12 @@ pub fn parse(bytes: &[u8]) -> Result { } let content = read_entry(&mut z, "content.xml").ok_or("ODT: content.xml is missing")?; let mut styles = Styles::default(); + let mut pages = Pages::default(); if let Some(s) = read_entry(&mut z, "styles.xml") { styles.read(&s, false); + pages = read_pages(&s); } + let page = pages.first.clone().unwrap_or_default(); styles.read(&content, true); let meta = read_entry(&mut z, "meta.xml").map(|m| read_meta(&m)).unwrap_or_default(); let mut total = 0u64; @@ -1367,6 +1530,12 @@ pub fn parse(bytes: &[u8]) -> Result { depth: 0, frame: None, in_desc: false, + pages: &pages, + sec: page.clone(), + page, + sec_from: 0, + sections: 0, + cols_at: None, }; events(&content, |ev| match ev { Ev::Start(q, a) => { @@ -1396,37 +1565,66 @@ pub fn parse(bytes: &[u8]) -> Result { blocks.push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new(), borderless: false })); } } + // An open section with nothing in it (after a column section at the end) isn't one: the + // section before it ends the document. + let mut last = body.sec; + if blocks.len() <= body.sec_from + && let Some(FBlock::Para(p)) = blocks.last_mut() + && let Some(s) = p.section.take() + { + last = *s; + } if blocks.is_empty() { blocks.push(FBlock::Para(Para::default())); } - Ok(Flow { blocks, meta }) + Ok((Flow { blocks, meta }, last)) } /// Parse an `.odt` package into a document. pub fn import(bytes: &[u8]) -> Result { - let mut doc = model::to_doc(&parse(bytes)?); - if let Ok(mut z) = zip::ZipArchive::new(Cursor::new(bytes)) - && let Some(s) = read_entry(&mut z, "styles.xml") - { - apply_page_layout(&s, &mut doc.last_section); - } + let (flow, last) = parse_full(bytes)?; + let mut doc = model::to_doc(&flow); + doc.last_section = last; Ok(doc) } -/// Read the page size, orientation and margins of the first master page in `styles.xml` into `sec`. -fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) { - let mut layouts: HashMap = HashMap::new(); +/// The page setup of each master page in `styles.xml` (ODF 1.3 §16.9), and of the first one, +/// which pages use until a paragraph names another. +#[derive(Default)] +struct Pages { + masters: HashMap, + first: Option, +} + +fn read_pages(xml: &[u8]) -> Pages { + let mut layouts: HashMap)> = HashMap::new(); let mut cur: Option = None; - let mut master: Option = None; + let mut masters: Vec<(String, String)> = Vec::new(); events(xml, |ev| match ev { Ev::Start(q, a) => match local(&q) { "page-layout" if q.starts_with("style:") => cur = get(&a, "style:name").map(String::from), "page-layout-properties" => { - if let Some(n) = &cur { - layouts.insert(n.clone(), a); + if let Some(n) = &cur + && layouts.len() < MAX_SECTIONS + { + layouts.insert(n.clone(), (a, None)); + } + } + "columns" if q.starts_with("style:") => { + if let Some(l) = cur.as_ref().and_then(|n| layouts.get_mut(n)) { + l.1 = Some(columns_of(&a)); + } + } + "column-sep" => { + if let Some(c) = cur.as_ref().and_then(|n| layouts.get_mut(n)).and_then(|l| l.1.as_mut()) { + c.separator = column_sep(&a); + } + } + "master-page" if masters.len() < MAX_SECTIONS => { + if let (Some(n), Some(l)) = (get(&a, "style:name"), get(&a, "style:page-layout-name")) { + masters.push((n.to_string(), l.to_string())); } } - "master-page" if master.is_none() => master = get(&a, "style:page-layout-name").map(String::from), _ => {} }, Ev::End(q) => { @@ -1436,8 +1634,34 @@ fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) { } Ev::Text(_) => {} }); - let Some(a) = master.and_then(|m| layouts.remove(&m)) else { return }; - let len = |k: &str| get(&a, k).and_then(length).filter(|v| *v >= 0.0); + let mut pages = Pages::default(); + for (name, layout) in masters { + let Some((a, cols)) = layouts.get(&layout) else { continue }; + let mut sec = SectionProps::default(); + apply_layout(a, &mut sec); + // A one-column layout keeps the default columns (and their gap). + if let Some(c) = cols.as_ref().filter(|c| c.count > 1) { + sec.columns = c.clone(); + } + if pages.first.is_none() { + pages.first = Some(sec.clone()); + } + pages.masters.entry(name).or_insert(sec); + } + pages +} + +/// Replace `sec` with the page setup of the first master page in `styles.xml`, if any. +#[cfg(test)] +fn apply_page_layout(xml: &[u8], sec: &mut SectionProps) { + if let Some(first) = read_pages(xml).first { + *sec = first; + } +} + +/// A `style:page-layout-properties` element's page size, orientation and margins into `sec`. +fn apply_layout(a: &Attrs, sec: &mut SectionProps) { + let len = |k: &str| get(a, k).and_then(length).filter(|v| *v >= 0.0); // Like the DOCX reader: a page is at least half an inch; at most 22 inches (Word's limit), so a hostile // `1e30pt` can't reach layout. let page = |k: &str| len(k).filter(|v| *v >= 36.0).map(|v| v.min(1584.0)); @@ -1445,7 +1669,7 @@ fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) { sec.page_w = w; sec.page_h = h; } - sec.landscape = match get(&a, "style:print-orientation") { + sec.landscape = match get(a, "style:print-orientation") { Some("landscape") => true, Some("portrait") => false, _ => sec.page_w > sec.page_h, @@ -1593,4 +1817,139 @@ mod tests { let row: Vec<_> = t.rows[0].iter().map(|c| (c.text(), c.shading)).collect(); assert_eq!(row, vec![("Red".to_string(), Some(Rgb(255, 0, 0))), ("Plain".to_string(), None)]); } + + /// #1379: three master pages (A4 upright; A4 wide with page numbering restarting at 1; A4 + /// upright again with numbering continuing) and a two-column `text:section` in between. + fn sections_package() -> Vec { + let styles = format!( + r#" + + + + + + + "# + ); + let content = format!( + r#" + + + + + + Opening wordsMore opening words + Wide part + AB + Left column textRight column text + Closing wordsLast line + "# + ); + package(&content, Some(&styles)) + } + + #[test] + fn master_pages_and_column_sections_become_sections() { + use wordcraft_doc::section::SectionStart; + let doc = import(§ions_package()).unwrap(); + let secs = doc.sections(); + let summary: Vec<_> = + secs.iter().map(|(_, s)| (s.start, s.landscape, s.page_w.round(), s.page_h.round(), s.columns.count, s.page_num_start)).collect(); + assert_eq!( + summary, + [ + (SectionStart::NextPage, false, 595.0, 842.0, 1, None), + (SectionStart::NextPage, true, 842.0, 595.0, 1, Some(1)), + (SectionStart::Continuous, true, 842.0, 595.0, 2, None), + (SectionStart::NextPage, false, 595.0, 842.0, 1, None), + ] + ); + // Margins come from each section's page layout; the columns from the section style. + assert!((secs[0].1.margin_left - 70.87).abs() < 0.01 && (secs[1].1.margin_left - 42.52).abs() < 0.01); + let cols = &secs[2].1.columns; + assert!((cols.space - 28.35).abs() < 0.01 && cols.separator, "{cols:?}"); + // Each section ends on its own last paragraph; the wide one ends after its table. + let text = |i: usize| match doc.body.get(i).map(|b| &**b) { + Some(wordcraft_doc::Block::Para(p)) => p.plain_text().to_string(), + _ => String::from(""), + }; + assert_eq!(text(secs[0].0), "More opening words"); + assert!(matches!(doc.body.get(secs[1].0 - 1).map(|b| &**b), Some(wordcraft_doc::Block::Table(_)))); + assert_eq!(text(secs[2].0), "Right column text"); + assert_eq!(text(secs[3].0), "Last line"); + } + + #[test] + fn one_master_page_stays_one_section() { + let styles = format!( + r#""# + ); + let content = format!( + r#"OneTwo"# + ); + let doc = import(&package(&content, Some(&styles))).unwrap(); + assert_eq!(doc.sections().len(), 1); + assert_eq!(doc.last_section.page_w.round(), 595.0); + } + + #[test] + fn hostile_sections_are_capped_and_clamped() { + // 20,000 master-page paragraphs give at most 10,000 sections; absurd column counts and + // gaps are clamped; a column section at the very end leaves no empty section after it. + let mut body = String::new(); + for _ in 0..20_000 { + body.push_str(r#"x"#); + } + body.push_str(r#"cols"#); + let content = format!( + r#"{body}"# + ); + let styles = format!( + r#""# + ); + let doc = import(&package(&content, Some(&styles))).unwrap(); + assert_eq!(doc.sections().len(), 10_000); + let content = format!( + r#"beforecols"# + ); + let doc = import(&package(&content, None)).unwrap(); + let secs = doc.sections(); + assert_eq!(secs.len(), 2); + assert_eq!(secs[1].1.columns.count, 45); + assert!(secs[1].1.columns.space <= 1584.0); + } + + #[test] + fn master_page_on_a_table_style_starts_a_section() { + // Review of #1379: a table style can name a master page too (ODF 1.3 §19.509), with its + // page-number restart on `style:table-properties`. An empty name on a child style means + // "no page style here", and a page-number restart without a master page starts nothing. + use wordcraft_doc::section::SectionStart; + let styles = format!( + r#" + + + + + "# + ); + let content = format!( + r#" + + + + + IntroNot a new page stylePage break only + Cell + After + "# + ); + let doc = import(&package(&content, Some(&styles))).unwrap(); + let secs = doc.sections(); + let summary: Vec<_> = secs.iter().map(|(i, s)| (*i, s.start, s.landscape, s.page_num_start)).collect(); + assert_eq!(summary, [(2, SectionStart::NextPage, false, None), (4, SectionStart::NextPage, true, Some(1))]); + assert!(matches!(doc.body.get(3).map(|b| &**b), Some(wordcraft_doc::Block::Table(_)))); + // A one-column page layout keeps the default column gap. + assert_eq!(secs[0].1.columns, wordcraft_doc::section::Columns::default()); + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0258d7495..1839580b5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-14 · **Change:** trivial (ODT sections on import, #1379; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -25,8 +25,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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 or encrypted files are dropped or refused. No writer | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379). No footnotes, comments, tracked changes, fields or RTL | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, revisions, section export, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | @@ -169,6 +169,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | +| 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) | | 2026-10-14 | trivial | HTML: `` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) | | 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) | | 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) | diff --git a/docs/gaps.md b/docs/gaps.md index 1ec89efd2..fdbb35d85 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-14 · **Change:** trivial (Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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-14 · **Change:** trivial (#19 narrowed: ODT sections on import, #1379; previously trivial: Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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. @@ -33,7 +33,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions, sections (RTF import done, #1378; RTF export and ODT left), RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions, sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | @@ -84,6 +84,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word | | 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | | 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | +| 2026-10-14 | trivial | #19 narrowed: ODT import keeps sections (master-page changes, columned `text:section`s, page-numbering restarts) (#1379) | | 2026-10-14 | trivial | RTF import keeps sections (#1378): gap 19 narrowed to RTF section export and ODT sections | | 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps | | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | From 5e8c5b3c4e86adf512ebbef488d4ae081b47adc3 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:07:08 +0200 Subject: [PATCH 077/199] PDF export: Hebrew and Arabic text extracts in reading order (#1383) (#1508) * #1383: WIP * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/pdf/src/lib.rs | 127 +++++++++++++++++-- crates/pdf/src/tests.rs | 250 +++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 5 files changed, 378 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 30b098920..5cabf372f 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-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; 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-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: progress log: Hebrew and Arabic text in exported PDFs copies and searches as whole words, #1383; previously trivial: progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; 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. @@ -153,6 +153,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | +| 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | | 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either | | 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged | @@ -325,6 +326,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | +| 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | | 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) | | 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) | diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index f1a14c09f..2a36110ae 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -241,6 +241,103 @@ fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Opti pb.finish() } +/// Whether a run's glyphs already run left to right, as PDF text extractors (PDFium in Chrome and +/// Edge, pdf.js, poppler) take them to: they read a run's glyphs as written left to right and turn +/// right-to-left words back into reading order themselves. Layout gives right-to-left text in +/// reading order. +fn left_to_right(glyphs: &[(u32, f32, f32)]) -> bool { + glyphs.windows(2).all(|w| matches!(w, [a, b] if a.1 <= b.1)) +} + +/// Per-glyph text ranges for clusters of several glyphs (a letter and its vowel marks) when each +/// glyph is one of the cluster's characters (through the font's character map, one glyph at most +/// left to take the character left over), so a viewer +/// reversing a right-to-left word puts every mark back after its letter. Other clusters, such +/// as a ligature drawn as two glyphs, keep their shared range. +fn split_clusters( + text: &str, + glyphs: &[(u32, f32, f32)], + ranges: &[std::ops::Range], + rev: &HashMap, +) -> Vec> { + let mut out = ranges.to_vec(); + let mut i = 0; + while i < ranges.len() { + let Some(r) = ranges.get(i) else { break }; + let n = ranges.get(i..).map_or(1, |rest| rest.iter().take_while(|x| *x == r).count()).max(1); + let chars: Vec<(usize, char)> = text.get(r.clone()).map(|t| t.char_indices().map(|(k, c)| (r.start + k, c)).collect()).unwrap_or_default(); + if n > 1 && chars.len() == n { + // Glyphs the character map knows take their characters; one left over (a letter's + // contextual form, which the map doesn't list) takes the one character left. + let mut used = vec![false; n]; + let mut of: Vec> = (i..i + n) + .map(|g| { + let c = rev.get(&glyphs.get(g)?.0)?; + let k = chars.iter().enumerate().position(|(k, (_, ch))| ch == c && !used.get(k).copied().unwrap_or(true))?; + *used.get_mut(k)? = true; + Some(k) + }) + .collect(); + let free: Vec = (0..n).filter(|k| !used.get(*k).copied().unwrap_or(true)).collect(); + if let ([k], [slot]) = (free.as_slice(), of.iter_mut().filter(|o| o.is_none()).collect::>().as_mut_slice()) { + **slot = Some(*k); + } + let parts: Option>> = of + .iter() + .map(|k| { + let (a, ch) = chars.get((*k)?)?; + Some(*a..a + ch.len_utf8()) + }) + .collect(); + if let (Some(parts), Some(dst)) = (parts, out.get_mut(i..i + n)) { + dst.clone_from_slice(&parts); + } + } + i += n; + } + out +} + +/// Indices of a run's glyphs in visual order, left to right: its clusters (consecutive glyphs +/// sharing a range in `clusters`, such as a letter and its marks, or a lam-alef ligature) sorted +/// by their leftmost glyph and kept together. Inside a cluster, glyphs go in reverse order of +/// their own text (`parts`) when it is right to left, so a viewer reversing the word reads the +/// letter before its marks. +fn visual_order(text: &str, glyphs: &[(u32, f32, f32)], clusters: &[std::ops::Range], parts: &[std::ops::Range]) -> Vec { + let mut groups: Vec<(f32, Vec)> = Vec::new(); + for (i, (_, x, _)) in glyphs.iter().enumerate() { + match groups.last_mut() { + Some((left, idx)) if i > 0 && clusters.get(i).is_some() && clusters.get(i) == clusters.get(i - 1) => { + *left = left.min(*x); + idx.push(i); + } + _ => groups.push((*x, vec![i])), + } + } + // Stable: clusters at the same x keep their order, as do glyphs sharing their text. + groups.sort_by(|a, b| a.0.total_cmp(&b.0)); + groups + .into_iter() + .flat_map(|(_, mut idx)| { + let rtl = idx.first().and_then(|&g| text.get(clusters.get(g)?.clone())).is_some_and(|t| t.chars().any(wordcraft_fonts::is_rtl)); + if rtl { + idx.sort_by_key(|&g| std::cmp::Reverse(parts.get(g).map_or(0, |r| r.start))); + } + idx + }) + .collect() +} + +/// The y most of a run's glyphs share: its baseline, which raised or lowered marks leave. +fn baseline(glyphs: &[(u32, f32, f32)]) -> Option { + let mut counts: HashMap = HashMap::new(); + for (_, _, y) in glyphs { + *counts.entry(y.to_bits()).or_insert(0) += 1; + } + // Ties go to the lower y bits, so the result doesn't depend on hash order. + counts.into_iter().max_by_key(|&(bits, n)| (n, std::cmp::Reverse(bits))).map(|(bits, _)| f32::from_bits(bits)) +} + /// A chart's or diagram's alt text: its own, else what it is ("chart", "diagram"). fn graphic_alt(alt: &str, kind: GraphicKind) -> &str { if alt.trim().is_empty() { kind.noun() } else { alt } @@ -1148,7 +1245,9 @@ impl Exporter<'_> { if !size.is_finite() || size <= 0.0 || size > 5000.0 { return; } - let Some(&(_, x0, y0)) = glyphs.first() else { return }; + if glyphs.is_empty() { + return; + } if !glyphs.iter().all(|(_, x, y)| ok(*x) && ok(*y)) { return; } @@ -1162,21 +1261,33 @@ impl Exporter<'_> { let valid = known.len() == glyphs.len() && known.iter().all(|r| r.start < r.end && text.get(r.clone()).is_some()); let (txt, ranges) = if valid { (text.to_string(), known.to_vec()) } else { self.map_text(face, glyphs, text) }; let upem = face.upem.max(1.0) as f32; - let kg: Vec = glyphs + // Right-to-left text is written left to right as it appears (see `left_to_right`); glyphs + // already in that order are written as they are. + let (order, ranges) = if left_to_right(glyphs) { + ((0..glyphs.len()).collect(), ranges) + } else { + let parts = split_clusters(&txt, glyphs, &ranges, &self.cmap(face)); + (visual_order(&txt, glyphs, &ranges, &parts), parts) + }; + let placed: Vec<(u32, f32, f32, std::ops::Range)> = + order.iter().filter_map(|&i| Some((glyphs.get(i).copied()?, ranges.get(i).cloned()?))).map(|((g, x, y), r)| (g, x, y, r)).collect(); + let Some(&(_, x0, y0, _)) = placed.first() else { return }; + // The run's baseline: the y most of its glyphs share (marks sit higher or lower). + let base = baseline(glyphs).unwrap_or(y0); + let kg: Vec = placed .iter() - .zip(ranges) .enumerate() - .map(|(k, ((g, x, y), r))| { - let adv = match glyphs.get(k + 1) { - Some((_, nx, _)) => (nx - x) / size, + .map(|(k, (g, x, y, r))| { + let adv = match placed.get(k + 1) { + Some((_, nx, _, _)) => (nx - x) / size, None => face.advance(*g) as f32 / upem, }; - KrillaGlyph::new(GlyphId::new(*g), adv, 0.0, (y0 - y) / size, 0.0, r, None) + KrillaGlyph::new(GlyphId::new(*g), adv, 0.0, (y0 - y) / size, 0.0, r.clone(), None) }) .collect(); let pushed = italic; if italic { - s.push_transform(&Transform::from_row(1.0, 0.0, -0.21, 1.0, 0.21 * y0, 0.0)); + s.push_transform(&Transform::from_row(1.0, 0.0, -0.21, 1.0, 0.21 * base, 0.0)); } s.set_fill(Some(fill(color, alpha))); s.set_stroke(if bold { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 94d928ab8..49c48a199 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1023,3 +1023,253 @@ fn internal_links_export_in_linear_time() { let ratio = fastest(&big) / fastest(&small).max(1e-9); assert!(ratio < 9.0, "4x the paragraphs and links took {ratio:.1}x as long (quadratic would be ~16x)"); } + +/// The reporter's four paragraphs from #1383 (Hebrew, Arabic and mixed right-to-left paragraphs, +/// and a Latin one), then right-to-left words with a lam-alef ligature and with vowel marks. +fn rtl_sample() -> Document { + let mut d = Document::from_text("שלום עולם\nمرحبا بالعالم\nייפוי כח מס' 123 – Power of Attorney\nHello world\nالسلام مَرْحَبًا שָׁלוֹם"); + // Right to left: all but "Hello world". + for i in [0, 1, 2, 4] { + let at = wordcraft_doc::Pos::body(i, 0); + d.format_paragraphs(&at, &at, &|p: &mut wordcraft_doc::ParaProps| p.bidi = Some(true)).unwrap(); + } + for (i, rtl) in [true, true, true, false, true].into_iter().enumerate() { + let Some(Block::Para(p)) = d.body.get(i).map(|b| &**b) else { panic!("paragraph {i}") }; + assert_eq!(p.props.bidi == Some(true), rtl, "paragraph {i}"); + } + d +} + +/// Each glyph's Unicode text in content-stream order, with its baseline y and its outline's left +/// and right edges (none for a space); `None` text for a glyph without a ToUnicode entry of its +/// own (a cluster's extra glyph, covered by ActualText). +type StreamGlyph = (Option, f64, Option<(f64, f64)>); +fn stream_glyphs(bytes: &[u8]) -> Vec { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + use hayro_syntax::Pdf; + struct Units(Vec); + impl Device<'_> for Units { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, g: &Glyph<'_>, t: kurbo::Affine, gt: kurbo::Affine, _: &Paint<'_>, mode: &GlyphDrawMode) { + if matches!(mode, GlyphDrawMode::Stroke(_)) { + return; + } + let text = match g.as_unicode() { + Some(hayro_cmap::BfString::Char(c)) => Some(c.to_string()), + Some(hayro_cmap::BfString::String(s)) => Some(s), + None => None, + }; + let edges = match g { + Glyph::Outline(o) if !o.outline().elements().is_empty() => { + use kurbo::Shape; + let b = (t * gt * o.outline()).bounding_box(); + Some((b.x0, b.x1)) + } + _ => None, + }; + self.0.push((text, (t * gt * kurbo::Point::ZERO).y, edges)); + } + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = Pdf::new(bytes.to_vec()).expect("parse"); + let cache = InterpreterCache::new(); + let mut out = Units(Vec::new()); + for page in pdf.pages().iter() { + let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut out); + } + out.0 +} + +/// Words as PDF viewers' text extraction (PDFium in Chrome and Edge, pdf.js, poppler) reads them: +/// glyphs are taken to be in visual order, so a word in a right-to-left script is read with its +/// glyphs reversed. Words end at spaces, at a jump back to the left, and where the +/// baseline moves (more than a raised mark does). +fn viewer_words(bytes: &[u8]) -> Vec { + let mut words = Vec::new(); + let mut word: Vec = Vec::new(); + let mut flush = |word: &mut Vec| { + if word.concat().chars().any(wordcraft_fonts::is_rtl) { + word.reverse(); + } + if !word.is_empty() { + words.push(word.concat()); + } + word.clear(); + }; + let mut last_y = None; + let mut last_edges: Option<(f64, f64)> = None; + for (text, y, edges) in stream_glyphs(bytes) { + // A new line, or a letter wholly left of the last one (a jump back): a new word. Hebrew + // and Arabic vowel marks sit anywhere over or under their letter. + let mark = text.as_deref().is_some_and(|t| t.chars().all(|c| matches!(c, '\u{591}'..='\u{5C7}' | '\u{64B}'..='\u{65F}' | '\u{670}'))); + let edges = edges.filter(|_| !mark); + let apart = matches!((last_edges, edges), (Some((l0, _)), Some((_, x1))) if x1 < l0); + if last_y.is_some_and(|l: f64| (l - y).abs() > 6.0) || apart { + flush(&mut word); + } + if !mark { + last_y = Some(y); + } + if edges.is_some() { + last_edges = edges; + } + let Some(text) = text else { continue }; + if text.chars().all(char::is_whitespace) { + flush(&mut word); + } else { + word.push(text); + } + } + flush(&mut word); + words +} + +/// Right-to-left text reads back whole (#1383): Chrome, Edge and other PDF viewers take a run's +/// glyphs to be written left to right as they appear, and reverse right-to-left words to get +/// their reading order. Written in reading order instead, `שלום` came back as `םולש`, and Arabic +/// came apart. +#[test] +fn right_to_left_text_extracts_in_logical_order() { + let d = rtl_sample(); + let words = viewer_words(&export(&d, &PdfOptions::default()).unwrap()); + for w in ["שלום", "עולם", "مرحبا", "بالعالم", "ייפוי", "כח", "123", "Power", "Attorney", "Hello", "world", "السلام", "مَرْحَبًا", "שָׁלוֹם"] + { + assert!(words.iter().any(|x| x == w), "{w:?} not extracted intact from {words:?}"); + } +} + +/// Every glyph's outline on the page, as a rounded bounding box in page space (sorted): what the +/// page looks like, whatever order the glyphs were written in. +fn glyph_boxes(bytes: &[u8]) -> Vec<[i64; 4]> { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + use hayro_syntax::Pdf; + use kurbo::Shape; + struct Boxes(Vec<[i64; 4]>); + impl Device<'_> for Boxes { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, g: &Glyph<'_>, t: kurbo::Affine, gt: kurbo::Affine, _: &Paint<'_>, mode: &GlyphDrawMode) { + if matches!(mode, GlyphDrawMode::Stroke(_)) { + return; + } + // Spaces have no outline: nothing to see. + if let Glyph::Outline(o) = g + && !o.outline().elements().is_empty() + { + let b = (t * gt * o.outline()).bounding_box(); + let r = |v: f64| (v * 10.0).round() as i64; + self.0.push([r(b.x0), r(b.y0), r(b.x1), r(b.y1)]); + } + } + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = Pdf::new(bytes.to_vec()).expect("parse"); + let cache = InterpreterCache::new(); + let mut out = Boxes(Vec::new()); + for page in pdf.pages().iter() { + let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut out); + } + out.0.sort_unstable(); + out.0 +} + +/// Writing right-to-left runs in reading order doesn't move a glyph: the page draws the same +/// glyph outlines at the same places as layout put them. +#[test] +fn right_to_left_glyphs_stay_where_layout_put_them() { + use kurbo::Shape; + let d = rtl_sample(); + let bytes = export(&d, &PdfOptions::default()).unwrap(); + let got = glyph_boxes(&bytes); + // Layout's glyphs, drawn as their outlines (y down from the page top; PDF is y up). + let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let page = &lay.pages[0]; + let h = f64::from(page.h); + let db = wordcraft_fonts::FontDb::global(); + let mut want = Vec::new(); + for item in wordcraft_layout::display::page_display(&d, page, &DisplayOptions::default()) { + let Draw::Glyphs { face, size, glyphs, .. } = item else { continue }; + let scale = f64::from(size) / face.upem.max(1.0); + for (g, x, y) in glyphs { + let mut o = (*db.outline(&face, g)).clone(); + if o.elements().is_empty() { + continue; + } + o.apply_affine( + kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, h]) + * kurbo::Affine::translate((f64::from(x), f64::from(y))) + * kurbo::Affine::scale(scale), + ); + let b = o.bounding_box(); + let r = |v: f64| (v * 10.0).round() as i64; + want.push([r(b.x0), r(b.y0), r(b.x1), r(b.y1)]); + } + } + want.sort_unstable(); + assert!(!want.is_empty()); + let close = got.len() == want.len() && got.iter().zip(&want).all(|(a, b)| a.iter().zip(b).all(|(p, q)| (p - q).abs() <= 2)); + assert!(close, "glyphs moved:\n got {got:?}\nwant {want:?}"); +} + +/// A separately formatted Latin run inside a right-to-left paragraph is written as it was, left +/// to right, and the Hebrew around it still reads back whole. +#[test] +fn latin_run_in_right_to_left_paragraph_reads_back_whole() { + let mut p = Paragraph::with_text("שלום ", CharProps::default()); + p.props.bidi = Some(true); + p.insert_text(p.len(), "Latin words", &CharProps { bold: Some(true), ..Default::default() }).unwrap(); + p.insert_text(p.len(), " עולם", &CharProps::default()).unwrap(); + let mut d = Document::new(); + d.body = vec![para_block(p)]; + let words = viewer_words(&export(&d, &PdfOptions::default()).unwrap()); + for w in ["שלום", "Latin", "words", "עולם"] { + assert!(words.iter().any(|x| x == w), "{w:?} not extracted intact from {words:?}"); + } +} + +/// Glyphs already running left to right keep their order: Latin runs are written as before. +#[test] +fn left_to_right_glyphs_keep_their_order() { + let glyphs = [(1, 0.0, 10.0), (2, 5.0, 10.0), (3, 5.0, 10.0), (4, 9.0, 10.0)]; + assert!(left_to_right(&glyphs)); +} + +/// Right-to-left glyphs (reading order, drawn right to left) are written left to right a +/// cluster at a time. A letter's vowel mark takes its own character and goes before the letter, +/// so a viewer reversing the word reads letter then mark; a lam-alef drawn as two glyphs keeps +/// its glyphs together in shaping order, so no word comes apart. +#[test] +fn right_to_left_clusters_are_written_whole_left_to_right() { + // Reading order: shin with qamats (mark glyph 2 drawn at x 28, then the letter, glyph 1, at + // x 30); lam-alef as two glyphs at x 20 and 16; a one-glyph lam-alef at x 8; final mem at 0. + let text = "שָלالاם"; + let glyphs = [(2, 28.0, 8.0), (1, 30.0, 10.0), (3, 20.0, 10.0), (4, 16.0, 10.0), (5, 8.0, 10.0), (6, 0.0, 10.0)]; + let ranges = [0..4, 0..4, 4..8, 4..8, 8..12, 12..14]; + let rev: std::collections::HashMap = [(1, 'ש'), (2, '\u{5B8}'), (6, 'ם')].into_iter().collect(); + assert!(!left_to_right(&glyphs)); + let parts = split_clusters(text, &glyphs, &ranges, &rev); + assert_eq!(parts, [2..4, 0..2, 4..8, 4..8, 8..12, 12..14]); + // Glyph by glyph by x this would be [5, 4, 3, 2, 0, 1]: the lam-alef's glyphs swapped. + assert_eq!(visual_order(text, &glyphs, &ranges, &parts), [5, 4, 2, 3, 0, 1]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1839580b5..618da1547 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (ODT sections on import, #1379; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-14 · **Change:** trivial (ODT sections on import, #1379; previously trivial: PDF export: Hebrew and Arabic text extracts as whole words, #1383; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -22,7 +22,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.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. 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 | -| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | +| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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 or encrypted files are dropped or refused. No writer | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379). No footnotes, comments, tracked changes, fields or RTL | @@ -170,6 +170,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | | 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) | +| 2026-10-14 | trivial | PDF export writes right-to-left runs' glyphs in visual order, so Chrome, Edge (PDFium) and poppler extract Hebrew and Arabic as whole words (vowelled words keep their letters in order but may extract with spaces inside), and Ctrl+F finds them (#1383). `/ReversedChars` (for strict file-order readers) covers plain runs and vowelled runs whose marks split into their own glyphs, not runs with ligatures or mixed directions | | 2026-10-14 | trivial | HTML: `` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) | | 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) | | 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) | diff --git a/docs/gaps.md b/docs/gaps.md index fdbb35d85..bd171d39e 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-14 · **Change:** trivial (#19 narrowed: ODT sections on import, #1379; previously trivial: Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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-14 · **Change:** trivial (#19 narrowed: ODT sections on import, #1379; previously trivial: #16: PDF export's right-to-left text extracts as whole words, #1383; previously trivial: Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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. @@ -30,7 +30,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 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); missing: Ink to Math, arrows in Ink to Shape, 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 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-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**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | -| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | +| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions, sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | @@ -85,6 +85,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | | 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | | 2026-10-14 | trivial | #19 narrowed: ODT import keeps sections (master-page changes, columned `text:section`s, page-numbering restarts) (#1379) | +| 2026-10-14 | trivial | #16: PDF export writes right-to-left glyphs in visual order, so Hebrew and Arabic extract, copy and search as whole words (#1383) | | 2026-10-14 | trivial | RTF import keeps sections (#1378): gap 19 narrowed to RTF section export and ODT sections | | 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps | | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | From 53b7debea5f420ec2751e7678bfe20f1510a82e5 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:07:28 +0200 Subject: [PATCH 078/199] Web: image paste goes to the active edit target (#1349) (#1513) * #1349: WIP * Co-Authored-By: Claude Opus 5.5 * #1349: pastes onto the page work again; the guard uses page focus Co-Authored-By: Claude Opus 5.5 * #1349: pastes onto the page work again; the guard uses page focus (clippy) Co-Authored-By: Claude Opus 5.5 * #1349: a focused text field outside dialogs ignores picture pastes (test) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- apps/wordcraft-web/src/web.rs | 10 ++- crates/ui-egui/src/lib.rs | 143 ++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 3 +- 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5cabf372f..e231b6240 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-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: progress log: Hebrew and Arabic text in exported PDFs copies and searches as whole words, #1383; previously trivial: progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; 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-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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. @@ -174,6 +174,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) | | 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures | | 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 | +| 2026-10-12 | On the web, pasting a picture while a comment, the Find box, a dialog field or the Backstage has the keyboard no longer inserts it into the document; it inserts once only when the page is the edit target (#1349) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -344,6 +345,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) | | 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) | | 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) | +| 2026-10-12 | trivial | Progress log: web picture paste respects the edit target (#1349) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index f6ccc776f..36a731478 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -48,7 +48,8 @@ pub fn start() { // Fonts the user added on an earlier visit (#582); the app notices them arrive. font_store::load_all(cc.egui_ctx.clone()); let inbox: Inbox = Inbox::default(); - if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) { + let pasted: Inbox = Inbox::default(); + if let Err(e) = listen_for_pasted_pictures(pasted.clone(), cc.egui_ctx.clone()) { log::error!("pasting pictures is unavailable: {e}"); } // The browser's locale picks the new document's paper, the units and the @@ -59,6 +60,8 @@ pub fn start() { let mut session = Session::new(doc); session.system_region = region; let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone())); + // Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349). + app.services.pasted = Some(pasted); app.autosave = false; // No document to show: start on File › Home, as the desktop app does (#766). if !sample { @@ -157,10 +160,11 @@ const MAX_PASTED_PICTURES: u32 = 20; /// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just /// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through -/// the inbox like dropped ones. A clipboard with text is left to eframe. +/// the app's `pasted` queue, which inserts them only when the page is the edit target (#1349). A clipboard with text is left to eframe. fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> { let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let on_paste = wasm_bindgen::closure::Closure::::new(move |e: web_sys::ClipboardEvent| { + // The target is always eframe's hidden text input while the page has focus; the app decides (#1349). let Some(data) = e.clipboard_data() else { return }; if !data.get_data("text").unwrap_or_default().is_empty() { return; @@ -181,7 +185,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { Ok(buf) => { let bytes = js_sys::Uint8Array::new(&buf).to_vec(); - // The inbox inserts files named like pictures; the engine reads the format from the bytes. + // The engine reads the format from the bytes; the name is ignored. inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes)); ctx.request_repaint(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cddfaebbc..38a34bfba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -107,6 +107,10 @@ pub struct Services { pub open_async: Option>, /// Web: files (name, bytes) delivered asynchronously (picker, drag and drop). pub inbox: Option, + /// Web: pictures pasted from the browser's clipboard (the bytes; the name is ignored). Kept + /// apart from `inbox` because a paste only lands in the document when the page is the edit + /// target (#1349), where a dropped or opened file always lands. + pub pasted: Option, /// Web: hand bytes to the browser as a download. An error means no download started (the /// browser can't tell the page whether the user then kept the file). pub download: Option Result<(), String>>>, @@ -166,6 +170,14 @@ pub enum DefaultApp { /// Files delivered asynchronously. pub type Inbox = std::sync::Arc)>>>; +/// Whether a pasted picture belongs in the document: the page has keyboard focus, and no +/// Backstage or dialog is open (#1349). A focused text field (comment, Find box) means the page +/// does not have focus, so `canvas_focused` already covers it; egui's own "wants keyboard input" +/// is true for the focused page itself and can't be used. +fn paste_target(canvas_focused: bool, backstage: bool, dialog: bool) -> bool { + canvas_focused && !backstage && !dialog +} + /// UI state that persists between runs (and that agents can read and set). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -1472,6 +1484,7 @@ impl WordApp { self.clear_stale_change_picture(); let _ = self.poll_file_dialog(); self.drain_inbox(); + self.drain_pasted(); self.watch_fonts(ctx); self.autosave_tick(now_ms()); if self.autosaves() && self.session.dirty { @@ -1746,6 +1759,21 @@ impl WordApp { } } + /// Pictures pasted in the browser: inserted once when the page is the edit target, dropped + /// otherwise so the focused field's own paste applies (a text field ignores a picture). + fn drain_pasted(&mut self) { + let Some(pasted) = self.services.pasted.clone() else { return }; + let pictures = std::mem::take(&mut *pasted.lock().unwrap_or_else(|e| e.into_inner())); + let target = paste_target(self.canvas.focused, self.ui.backstage, self.dialog.is_some() || self.file_dialog_open()); + for (_, bytes) in pictures { + if !target { + log::debug!("pasted picture ignored: the document is not the edit target"); + continue; + } + let _ = self.insert_or_change_picture(json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})); + } + } + fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -4728,6 +4756,121 @@ mod tests { a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0) } + /// A picture pasted in the browser lands only when the page is the edit target (#1349); + /// dropped or opened files always land. + #[test] + fn pasted_picture_inserts_only_when_the_page_is_the_edit_target() { + let mut a = app(); + let q: Inbox = Inbox::default(); + a.services.pasted = Some(q.clone()); + let push = |a: &mut WordApp, c| { + // Collapse after the last picture so an insertion adds one instead of replacing it. + a.session.run("select.collapse", &json!({"end": true})).unwrap(); + q.lock().unwrap().push(("pasted.png".into(), png_bytes(c))); + }; + // The page has focus: inserted once. + a.canvas.focused = true; + push(&mut a, [200, 30, 30, 255]); + a.drain_pasted(); + assert_eq!(object_count(&mut a), 1); + assert!(q.lock().unwrap().is_empty(), "the queue is drained, so no second insertion"); + a.drain_pasted(); + assert_eq!(object_count(&mut a), 1); + // A dialog is open. + a.run("ui.dialog", json!({"name": "find"})).unwrap(); + assert!(a.dialog.is_some()); + push(&mut a, [1, 2, 3, 255]); + a.drain_pasted(); + assert_eq!(object_count(&mut a), 1); + a.dialog = None; + // The Backstage is open. + a.ui.backstage = true; + push(&mut a, [1, 2, 3, 255]); + a.drain_pasted(); + assert_eq!(object_count(&mut a), 1); + assert!(q.lock().unwrap().is_empty(), "ignored pictures are dropped, not kept for later"); + a.ui.backstage = false; + // A dropped file still inserts with the page unfocused. + a.canvas.focused = false; + a.session.run("select.collapse", &json!({"end": true})).unwrap(); + a.services.inbox = Some(Inbox::default()); + a.services.inbox.as_ref().unwrap().lock().unwrap().push(("drop.png".into(), png_bytes([5, 5, 5, 255]))); + a.drain_inbox(); + assert_eq!(object_count(&mut a), 2); + } + + fn paste_harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { + 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(); + } + h + } + + /// Through the real frame loop (#1349): a paste onto the focused page inserts once; the + /// focused page must not look like a text field to the guard. + #[test] + fn a_paste_onto_the_focused_page_inserts_one_picture() { + let mut a = app(); + let q: Inbox = Inbox::default(); + a.services.pasted = Some(q.clone()); + let mut h = paste_harness(a); + assert!(h.state().canvas.focused); + q.lock().unwrap().push(("pasted.png".into(), png_bytes([9, 9, 9, 255]))); + h.step(); + assert_eq!(object_count(h.state_mut()), 1); + h.step(); + assert_eq!(object_count(h.state_mut()), 1, "once"); + } + + /// A real text field with focus (the Find box) takes the input: the picture is dropped. + #[test] + fn a_paste_with_the_find_dialog_open_is_ignored() { + let mut a = app(); + let q: Inbox = Inbox::default(); + a.services.pasted = Some(q.clone()); + a.run("ui.dialog", json!({"name": "find"})).unwrap(); + let mut h = paste_harness(a); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("find_what"))); + h.step(); + h.step(); + assert!(!h.state().canvas.focused); + q.lock().unwrap().push(("pasted.png".into(), png_bytes([9, 9, 9, 255]))); + h.step(); + assert_eq!(object_count(h.state_mut()), 0); + assert!(q.lock().unwrap().is_empty()); + } + + /// A text field outside any dialog (the Navigation pane's search) with focus takes the input: + /// the page is not focused, and the picture is dropped (#1349). + #[test] + fn a_paste_with_a_pane_text_field_focused_is_ignored() { + let mut a = app(); + let q: Inbox = Inbox::default(); + a.services.pasted = Some(q.clone()); + a.run("view.navigationPane", json!({"value": true})).unwrap(); + let mut h = paste_harness(a); + // Focus the search box for real (as Ctrl+F does), once the page has settled. + h.state_mut().canvas.want_focus = false; + h.state_mut().nav_search_focus = true; + h.step(); + h.step(); + let app = h.state(); + assert!(app.dialog.is_none() && !app.ui.backstage, "no dialog, no Backstage"); + assert!(!app.canvas.focused, "the search box has the focus, not the page"); + q.lock().unwrap().push(("pasted.png".into(), png_bytes([9, 9, 9, 255]))); + h.step(); + assert_eq!(object_count(h.state_mut()), 0); + assert!(q.lock().unwrap().is_empty()); + } + /// A pending Change Picture replaces the selected image instead of inserting (#147). #[test] fn change_picture_replaces_instead_of_inserting() { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3e7b01f17..b3ca7d96e 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: dark page dims highlights, #1252; previously trivial: full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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 @@ -57,6 +57,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Dark page (View › Switch Modes): highlights and character shading are dimmed toward black, keeping their hue (yellow turns a dim olive), instead of having their lightness inverted, which left yellow bright under white text; text on them draws light (white for black and Automatic), coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule. Light page, print and exports unchanged. Unlike Word, Automatic text stays white on the dark page (#1252) | | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | | 2026-10-14 | trivial | File dialogs: the system Save As type list names each format, the document's own first (#1399) | +| 2026-10-12 | trivial | Web: a pasted picture inserts only when the page is the edit target (page focused; no dialog, Backstage or text field such as a comment or Find box taking input), once; otherwise it is ignored (#1349) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | From 37d41150e6967eab0c11c0adff1dc8ce707c2489 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:07:47 +0200 Subject: [PATCH 079/199] Track Changes and content controls: table delete, Change Case and Unlink Fields respect them (#1329) (#1514) * #1329: WIP * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/controls.rs | 14 + crates/engine/src/cmd/fields.rs | 9 +- crates/engine/src/cmd/format.rs | 112 +++++- crates/engine/src/cmd/table.rs | 109 +++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_revision_checks.rs | 403 +++++++++++++++++++++ docs/typing-parity.md | 8 +- 8 files changed, 633 insertions(+), 28 deletions(-) create mode 100644 crates/engine/src/tests_revision_checks.rs diff --git a/ROADMAP.md b/ROADMAP.md index e231b6240..68835cacf 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-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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-14 · **Change:** 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: 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. @@ -175,6 +175,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures | | 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 | | 2026-10-12 | On the web, pasting a picture while a comment, the Find box, a dialog field or the Backstage has the keyboard no longer inserts it into the document; it inserts once only when the page is the edit target (#1349) | +| 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -346,6 +347,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) | | 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) | | 2026-10-12 | trivial | Progress log: web picture paste respects the edit target (#1349) | +| 2026-10-14 | trivial | Progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 374a27f5e..6f8e2dda8 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -347,6 +347,20 @@ pub fn check_deletions<'a>(s: &mut Session, story: StoryRef, deletions: impl Int Ok(()) } +/// Before an edit that rewrites the text of `a..b` in place (Change Case, unlinking a field): +/// refuse when it touches the content of a control that can't be edited. Unlike a deletion, +/// the control itself stays, so a control locked only against deletion takes the edit. +pub fn check_edit(s: &mut Session, a: &Pos, b: &Pos) -> Result<(), CmdError> { + if a.story != b.story { + return Ok(()); + } + let refused = s.doc.control_ranges(a.story).into_iter().any(|r| { + let cs = r.content_start(); + !r.control.lock.can_edit() && (&a.path, a.off) < (&r.end.path, r.end.off) && (&b.path, b.off) > (&cs.path, cs.off) + }); + if refused { Err(locked(s, CANT_EDIT)) } else { Ok(()) } +} + /// After the user deleted something: a control left empty shows its placeholder again. pub fn restore_placeholder(s: &mut Session) -> Result<(), CmdError> { let at = s.sel.focus.clone(); diff --git a/crates/engine/src/cmd/fields.rs b/crates/engine/src/cmd/fields.rs index 825a0cb83..b1a025e1f 100644 --- a/crates/engine/src/cmd/fields.rs +++ b/crates/engine/src/cmd/fields.rs @@ -208,6 +208,14 @@ fn unlink(s: &mut Session, _: &Value) -> CmdResult { if fields.is_empty() { return Err(none_selected()); } + // Unlinking rewrites the field into text: refused in content locked against editing. + // Not tracked (Word doesn't track it either); the result stays as ordinary text. + let width = OBJ.len_utf8(); + for f in &fields { + let end = f.end.as_ref().unwrap_or(&f.at); + let end = Pos { off: end.off.saturating_add(width), ..end.clone() }; + super::controls::check_edit(s, &f.at, &end)?; + } let texts: Vec = fields.iter().map(|f| if f.end.is_none() { shown_text(s, f) } else { String::new() }).collect(); // Markers to remove and text to put in their place, last first so earlier positions hold. let mut edits: Vec<(Pos, String)> = Vec::new(); @@ -219,7 +227,6 @@ fn unlink(s: &mut Session, _: &Value) -> CmdResult { } edits.sort_by(|a, b| b.0.cmp(&a.0)); edits.dedup_by(|a, b| a.0 == b.0); - let width = OBJ.len_utf8(); for (at, text) in &edits { let para = s.doc.para_mut(at.story, &at.path)?; if para.object_at(at.off).is_none() { diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index dc5503d22..4c56d1107 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1,10 +1,10 @@ //! Character formatting (Home › Font). use serde_json::{Value, json}; -use wordcraft_doc::Pos; use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure}; use wordcraft_doc::props::{Border, CharProps, Highlight, Rgb, TextColor, Underline, VertAlign}; use wordcraft_doc::resolve::ResolvedChar; +use wordcraft_doc::{Pos, RevisionKind}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -385,7 +385,22 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult { if a == b { return sel_result(s); } - let text = s.doc.copy_range(&a, &b).plain_text(); + // Changing case rewrites the characters: refused in content locked against editing. + super::controls::check_edit(s, &a, &b)?; + // Only text that isn't a tracked deletion counts, for the mode and the conversion: after a + // tracked change of case the old letters are still there, deleted (#1329). + let text: String = s + .doc + .paths_between(&a, &b) + .iter() + .filter_map(|path| { + let p = s.doc.para(a.story, path)?; + let from = if *path == a.path { a.off } else { 0 }; + let to = if *path == b.path { b.off } else { p.len() }; + Some(live_chars(p, from, to).into_iter().map(|(_, c)| c).collect::()) + }) + .collect::>() + .join("\n"); let mode = p::str(v, "mode").map(str::to_string).unwrap_or_else(|| { // Shift+F3 cycles: lower → UPPER → Title → lower. if text.chars().any(char::is_lowercase) && text.chars().any(char::is_uppercase) && !is_title(&text) { @@ -404,28 +419,64 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult { let Some(p) = s.doc.para(a.story, &path) else { continue }; let from = if path == a.path { a.off } else { 0 }; let to = if path == b.path { b.off } else { p.len() }; - let Some(src) = p.text.get(from..to) else { continue }; - case_groups(p, from, src, &convert_chars(src, &mode, from == 0)) + let chars = live_chars(p, from, to); + let src: String = chars.iter().map(|(_, c)| c).collect(); + case_groups(p, &chars, &convert_chars(&src, &mode, from == 0)) }; if groups.is_empty() { continue; } + // With Track Changes on, a changed stretch is a tracked deletion of the old characters + // followed by a tracked insertion of the new, so Reject All restores the old case (#1329). + // Text this author inserted is rewritten in place (it stays one insertion); text already + // deleted isn't in the stretches. + let tracked = s.doc.settings.track_changes; + let kinds: Vec = { + let Some(p) = s.doc.para(a.story, &path) else { continue }; + groups + .iter() + .map(|(start, _, _)| { + let c = p.props_of_char(*start); + let own = c.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == s.author); + if tracked && !own { Tracked::Replace } else { Tracked::No } + }) + .collect() + }; + let revs = if kinds.contains(&Tracked::Replace) { + Some((super::new_revision(s, RevisionKind::Delete), super::new_revision(s, RevisionKind::Insert))) + } else { + None + }; // Each stretch keeps its own run's formatting (#421); right to left so offsets hold. let para = s.doc.para_mut(a.story, &path)?; - for (start, end, new) in groups.iter().rev() { - if new.len() == end - start { - if para.text.get(*start..*end).is_some() { - para.text.replace_range(*start..*end, new); + let mut spans: Vec<(usize, usize, usize)> = Vec::with_capacity(groups.len()); + for ((start, end, new), kind) in groups.iter().zip(&kinds).rev() { + let old_len = end.saturating_sub(*start); + match (kind, revs) { + (Tracked::Replace, Some((del, ins))) => { + let mut props = para.props_of_char(*start).clone(); + para.format(*start, *end, &|c| c.del = Some(del))?; + (props.ins, props.del, props.fmt_change) = (Some(ins), None, None); + para.insert_text(*end, new, &props)?; + spans.push((*start, *end, old_len.saturating_add(new.len()))); + } + _ => { + if new.len() == old_len { + if para.text.get(*start..*end).is_some() { + para.text.replace_range(*start..*end, new); + } + } else { + let props = para.props_of_char(*start).clone(); + para.delete(*start, *end)?; + para.insert_text(*start, new, &props)?; + } + spans.push((*start, *end, new.len())); } - } else { - let props = para.props_of_char(*start).clone(); - para.delete(*start, *end)?; - para.insert_text(*start, new, &props)?; } } + spans.reverse(); para.touch(); // The selection keeps covering the converted text when its length changed (#420). - let spans: Vec<(usize, usize, usize)> = groups.iter().map(|(x, y, n)| (*x, *y, n.len())).collect(); for end in [&mut s.sel.anchor, &mut s.sel.focus] { if end.story == a.story && end.path == path { end.off = map_offset(&spans, end.off); @@ -435,14 +486,39 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -/// Group the characters of `src` (at `from` in `p`) whose converted form `conv` differs into -/// stretches that stay inside one run: `(start, end, replacement)`, in text order. -fn case_groups(p: &wordcraft_doc::Paragraph, from: usize, src: &str, conv: &[String]) -> Vec<(usize, usize, String)> { +/// How Change Case rewrites one stretch under Track Changes. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tracked { + /// In place: tracking is off, or the text is this author's own insertion. + No, + /// A tracked deletion of the old text and a tracked insertion of the new. + Replace, +} + +/// The characters of `p` in `from..to` that aren't tracked deletions, with their offsets. +fn live_chars(p: &wordcraft_doc::Paragraph, from: usize, to: usize) -> Vec<(usize, char)> { + let mut out = Vec::new(); + for (r, c) in p.run_ranges() { + let (x, y) = (r.start.max(from), r.end.min(to)); + if c.del.is_some() || x >= y { + continue; + } + if let Some(t) = p.text.get(x..y) { + out.extend(t.char_indices().map(|(i, ch)| (x + i, ch))); + } + } + out +} + +/// Group the characters `chars` (offsets in `p`) whose converted form `conv` differs into +/// stretches of adjacent characters that stay inside one run: `(start, end, replacement)`, in +/// text order. +fn case_groups(p: &wordcraft_doc::Paragraph, chars: &[(usize, char)], conv: &[String]) -> Vec<(usize, usize, String)> { let runs: Vec> = p.run_ranges().map(|(r, _)| r).collect(); let run_of = |off: usize| runs.iter().position(|r| r.contains(&off)); let mut out: Vec<(usize, usize, String, Option)> = Vec::new(); - for ((i, c), new) in src.char_indices().zip(conv) { - let (start, end) = (from + i, from + i + c.len_utf8()); + for (&(start, c), new) in chars.iter().zip(conv) { + let end = start + c.len_utf8(); let mut buf = [0u8; 4]; if new.as_str() == c.encode_utf8(&mut buf) { continue; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 3c153d860..071dc6f3a 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::collate::{SortKey, sort_key}; use wordcraft_doc::locale_number::{format_number, parse_number}; use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign}; -use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block}; +use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -26,9 +26,15 @@ pub fn specs() -> Vec { t(CommandSpec::new("table.deleteCells", "Delete Cells", "Table Layout › Rows & Columns › Delete", |s, _| { let (tp, r, c) = cell(s)?; let story = s.sel.focus.story; - let t = s.doc.table_mut(story, &tp)?; - if let Some(row) = t.rows.get_mut(r) - && row.cells.len() > 1 + // A row's last cell stays. + if !s.doc.table(story, &tp).and_then(|t| t.rows.get(r)).is_some_and(|row| row.cells.len() > 1 && c < row.cells.len()) { + return fix_caret(s, &tp, r, 0); + } + let spans = text_spans(s, story, &[cell_prefix(&tp, r, c)]); + if let Some(done) = guard_delete(s, &spans)? { + return Ok(done); + } + if let Some(row) = s.doc.table_mut(story, &tp)?.rows.get_mut(r) && c < row.cells.len() { row.cells.remove(c); @@ -497,10 +503,15 @@ fn del_row(s: &mut Session, _: &Value) -> CmdResult { let (tp, r, _) = cell(s)?; let rows: std::collections::BTreeSet = selected_cells(s).into_iter().map(|x| x.0).collect(); let story = s.sel.focus.story; - let t = s.doc.table_mut(story, &tp)?; - if rows.len() >= t.rows.len() { + if rows.len() >= s.doc.table(story, &tp).map_or(0, |t| t.rows.len()) { return del_table(s, &Value::Null); } + let prefixes: Vec> = rows.iter().map(|rr| tp.0.iter().copied().chain([*rr as u32]).collect()).collect(); + let spans = text_spans(s, story, &prefixes); + if let Some(done) = guard_delete(s, &spans)? { + return Ok(done); + } + let t = s.doc.table_mut(story, &tp)?; for rr in rows.iter().rev() { t.delete_row(*rr); } @@ -511,7 +522,7 @@ fn del_col(s: &mut Session, _: &Value) -> CmdResult { let (tp, r, c) = cell(s)?; let cells = selected_cells(s); let story = s.sel.focus.story; - let t = s.doc.table_mut(story, &tp)?; + let Some(t) = s.doc.table(story, &tp) else { return Err(CmdError::Failed("no table".into())) }; if t.grid.len() <= 1 { return del_table(s, &Value::Null); } @@ -528,6 +539,23 @@ fn del_col(s: &mut Session, _: &Value) -> CmdResult { if cols.len() >= t.grid.len() { return del_table(s, &Value::Null); } + // The cells that go with those columns, and their text: those all of whose columns go + // (a merged cell that also spans a kept column just narrows, keeping its text). + let mut prefixes = Vec::new(); + for (rr, row) in t.rows.iter().enumerate() { + for (cc, x) in row.cells.iter().enumerate() { + let g = t.grid_col(rr, cc); + let covered = g..g.saturating_add(x.span()).min(t.grid.len()); + if !covered.is_empty() && covered.clone().all(|k| cols.contains(&k)) { + prefixes.push(cell_prefix(&tp, rr, cc)); + } + } + } + let spans = text_spans(s, story, &prefixes); + if let Some(done) = guard_delete(s, &spans)? { + return Ok(done); + } + let t = s.doc.table_mut(story, &tp)?; for g in cols.iter().rev() { t.delete_col(*g); } @@ -541,11 +569,78 @@ fn del_col(s: &mut Session, _: &Value) -> CmdResult { fn del_table(s: &mut Session, _: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; let story = s.sel.focus.story; + let spans = text_spans(s, story, std::slice::from_ref(&tp.0)); + if let Some(done) = guard_delete(s, &spans)? { + return Ok(done); + } s.doc.remove_block(story, &tp)?; s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp, off: 0 })); sel_result(s) } +/// The path prefix of cell (`r`, `c`) of the table at `tp`. +fn cell_prefix(tp: &Path, r: usize, c: usize) -> Vec { + tp.0.iter().copied().chain([r as u32, c as u32]).collect() +} + +/// The text under each path prefix (a cell, a row or a whole table): from its first paragraph's +/// start to its last paragraph's end. Prefixes without paragraphs are left out. +fn text_spans(s: &Session, story: StoryRef, prefixes: &[Vec]) -> Vec<(Pos, Pos)> { + let paths = s.doc.para_paths(story); + prefixes + .iter() + .filter_map(|pre| { + let mut inside = paths.iter().filter(|p| p.0.starts_with(pre)); + let first = inside.next()?.clone(); + let last = inside.next_back().cloned().unwrap_or_else(|| first.clone()); + let end = s.doc.para(story, &last).map_or(0, Paragraph::len); + Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: end })) + }) + .collect() +} + +/// Before a table deletion removes the text `spans`: refuse when that deletes a content control +/// locked against deletion or edits content locked against editing (#1329). With Track Changes +/// on, text not yet deleted is marked deleted instead and the structure stays (like Word deleting +/// a table with tracking on; the model has no tracked row or column deletion), returning the +/// result. With no text at all (an empty table, or one whose deletion was accepted) there is +/// nothing for Reject All to restore and the structure goes as without tracking; with only text +/// already marked deleted, nothing changes (removing it would lose what Reject All restores). +fn guard_delete(s: &mut Session, spans: &[(Pos, Pos)]) -> Result, CmdError> { + for (a, b) in spans { + super::controls::check_delete(s, a, b)?; + } + if !s.doc.settings.track_changes { + return Ok(None); + } + if !spans.iter().any(|(a, b)| has_text(s, a, b, true)) { + if spans.iter().any(|(a, b)| has_text(s, a, b, false)) { + let msg = "The table's text is already marked deleted; accept or reject the change first."; + s.status = msg.to_string(); + return Err(CmdError::Failed(msg.to_string())); + } + return Ok(None); + } + let rid = super::new_revision(s, RevisionKind::Delete); + for (a, b) in spans.iter().rev() { + super::track_delete_as(s, a, b, rid)?; + } + if let Some((a, _)) = spans.first() { + s.sel = Selection::caret(s.doc.clamp(a)); + } + sel_result(s).map(Some) +} + +/// Whether `a..b` holds text: any text, or (`live`) text that isn't a tracked deletion. +fn has_text(s: &Session, a: &Pos, b: &Pos, live: bool) -> bool { + s.doc.paths_between(a, b).iter().any(|path| { + let Some(p) = s.doc.para(a.story, path) else { return false }; + let from = if *path == a.path { a.off } else { 0 }; + let to = if *path == b.path { b.off } else { p.len() }; + p.run_ranges().any(|(r, c)| (!live || c.del.is_none()) && r.end > from && r.start < to && !r.is_empty()) + }) +} + fn merge(s: &mut Session, _: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; let cells = selected_cells(s); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 99edb750b..55e825ad2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -171,6 +171,8 @@ mod tests_bidi; #[cfg(test)] mod tests_fonts; #[cfg(test)] +mod tests_revision_checks; +#[cfg(test)] mod tests_typing; #[cfg(test)] mod tests_wordart; diff --git a/crates/engine/src/tests_revision_checks.rs b/crates/engine/src/tests_revision_checks.rs new file mode 100644 index 000000000..71ce49ab1 --- /dev/null +++ b/crates/engine/src/tests_revision_checks.rs @@ -0,0 +1,403 @@ +//! #1329: table deletion, Change Case and field unlink respect locked content controls and +//! Track Changes (Reject All restores what they changed). + +use serde_json::{Value, json}; +use wordcraft_doc::para::{InlineObject, OBJ}; +use wordcraft_doc::{Pos, StoryRef, Table}; + +use crate::{Selection, Session}; + +fn run(s: &mut Session, id: &str, v: Value) -> Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// The first paragraph's text without its tracked deletions. +fn visible(s: &Session) -> String { + let Some(p) = s.doc.para_at(&Pos::body(0, 0)) else { return String::new() }; + p.run_ranges().filter(|(_, c)| c.del.is_none()).filter_map(|(r, _)| p.text.get(r)).collect::().replace(OBJ, "") +} + +fn table(s: &Session) -> Option<&Table> { + s.doc.body.iter().find_map(|b| b.as_table()) +} + +/// Every cell's text, row by row (deleted text included). +fn cells(s: &Session) -> Vec> { + table(s) + .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() +} + +/// Whether every character of every cell in `which` is a tracked deletion. +fn cells_deleted(s: &Session, which: &[(usize, usize)]) -> bool { + let Some(t) = table(s) else { return false }; + which.iter().all(|(r, c)| { + let cell = &t.rows[*r].cells[*c]; + cell.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.run_ranges().all(|(_, props)| props.del.is_some())) + }) +} + +/// A 2×2 table holding A1 B1 / A2 B2, the caret in A1 (at its start). +fn table_session() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() { + if i > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": t})); + } + caret_in(&mut s, 0, 0, 0); + s +} + +fn caret_in(s: &mut Session, r: u32, c: u32, off: usize) { + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off }); +} + +/// [`table_session`] with "A1" in a content control tagged `ctl`, locked with `lock`. +fn locked_table(lock: &str) -> Session { + let mut s = table_session(); + let at = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; + // A partial selection inside the paragraph (not the whole paragraph) wraps inline. + s.sel = Selection { anchor: at(0), focus: at(1) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); + caret_in(&mut s, 0, 0, 0); + s +} + +#[test] +fn table_deletes_refuse_a_control_locked_against_deletion() { + for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { + let mut s = locked_table("lockDelete"); + let before = s.doc.clone(); + assert!(s.run(id, &json!({})).is_err(), "{id} deleted a locked control"); + assert_eq!(s.doc, before, "{id} changed the document"); + assert!(s.status.contains("can't be deleted"), "{id}: {}", s.status); + } +} + +#[test] +fn tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it() { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteTable", json!({})); + // Like Word: the structure stays, its text is marked deleted. + assert!(table(&s).is_some(), "the table was removed under Track Changes"); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(cells_deleted(&s, &[(0, 0), (0, 1), (1, 0), (1, 1)])); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(!cells_deleted(&s, &[(0, 0)]) && s.doc.revisions.is_empty()); +} + +#[test] +fn tracked_row_column_and_cell_deletes_mark_their_text_deleted() { + for (id, gone, kept) in [ + ("table.deleteRow", vec![(0, 0), (0, 1)], vec![(1, 0), (1, 1)]), + ("table.deleteColumn", vec![(0, 0), (1, 0)], vec![(0, 1), (1, 1)]), + ("table.deleteCells", vec![(0, 0)], vec![(0, 1), (1, 0), (1, 1)]), + ] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, id, json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id} changed the structure"); + assert!(cells_deleted(&s, &gone), "{id}: text not marked deleted"); + for k in &kept { + assert!(!cells_deleted(&s, &[*k]), "{id}: {k:?} marked deleted"); + } + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(s.doc.revisions.is_empty(), "{id}"); + } +} + +#[test] +fn tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original() { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + // One insertion by this author, one by another. + caret_in(&mut s, 0, 0, 2); + run(&mut s, "text.insert", json!({"text": "x"})); + let me = s.author.clone(); + s.author = "Other".into(); + caret_in(&mut s, 1, 1, 2); + run(&mut s, "text.insert", json!({"text": "y"})); + s.author = me; + caret_in(&mut s, 0, 0, 0); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_some()); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(s.doc.revisions.is_empty()); +} + +/// "hello world" with "hello" in a rich text control tagged `ctl`. +fn hello_in_control(lock: Option<&str>) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello world"})); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 5) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + if let Some(l) = lock { + run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); + } + s +} + +#[test] +fn change_case_refuses_content_locked_against_editing() { + let mut s = hello_in_control(Some("lockEdit")); + let w = OBJ.len_utf8(); + // Just the control's content, and the whole paragraph. + for (a, b) in [(w, w + 5), (0, 11 + 2 * w)] { + s.sel = Selection { anchor: Pos::body(0, a), focus: Pos::body(0, b) }; + let before = s.doc.clone(); + assert!(s.run("format.changeCase", &json!({"mode": "upper"})).is_err(), "{a}..{b}"); + assert_eq!(s.doc, before); + } + // Locked against deletion only: the content may change. + let mut s = hello_in_control(Some("lockDelete")); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 11 + 2 * w) }; + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); +} + +#[test] +fn tracked_change_case_rejects_back_to_the_original_case() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello World"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "HELLO WORLD"); + // The selection still covers the converted text. + let (a, b) = s.sel.ordered(); + assert_eq!((a.off, b.off), (0, s.doc.para_at(&a).map(|p| p.len()).unwrap_or(0))); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hello World"); + assert!(s.doc.revisions.is_empty()); + // Accept All keeps the new case. + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); +} + +#[test] +fn tracked_change_case_of_own_insertion_stays_an_insertion() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "keep "})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.insert", json!({"text": "new"})); + s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8) }; + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "keep NEW", "no deleted copy of text that never existed"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "keep "); +} + +/// "Made by {AUTHOR} on " with "by {AUTHOR} on" in a rich text control tagged `ctl`. +fn field_in_control(lock: &str) -> (Session, Pos) { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.doc.core.creator = "Ada".into(); + run(&mut s, "text.insert", json!({"text": "Made by "})); + run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); + run(&mut s, "text.insert", json!({"text": " on "})); + let w = OBJ.len_utf8(); + s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8 + w + 3) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Field { .. }))).unwrap(); + (s, Pos::body(0, off)) +} + +#[test] +fn unlink_refuses_a_field_in_content_locked_against_editing() { + let (mut s, at) = field_in_control("lockEdit"); + s.sel = Selection::caret(at); + let before = s.doc.clone(); + assert!(s.run("fields.unlink", &json!({})).is_err()); + assert_eq!(s.doc, before); + // Locked against deletion only: unlinking is an edit of the content, allowed. + let (mut s, at) = field_in_control("lockDelete"); + s.sel = Selection::caret(at); + run(&mut s, "fields.unlink", json!({})); + assert!(s.doc.plain_text(StoryRef::Body).contains("by Ada on")); +} + +#[test] +fn tracked_unlink_keeps_the_result_as_plain_text() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.doc.core.creator = "Ada".into(); + run(&mut s, "text.insert", json!({"text": "Made by "})); + run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.sel = Selection::caret(Pos::body(0, 8)); + run(&mut s, "fields.unlink", json!({})); + // Like Word, not tracked: the result is ordinary text, shown and kept by Reject All. + assert_eq!(visible(&s), "Made by Ada"); + assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.is_empty()); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Made by Ada"); +} + +#[test] +fn tracked_delete_table_removes_a_table_with_no_live_text() { + // Delete, Accept All (an empty grid is left), Delete again: the table goes. + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteTable", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert!(table(&s).is_some()); + caret_in(&mut s, 0, 0, 0); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_none(), "an empty table can't be deleted under Track Changes"); + assert!(s.doc.revisions.is_empty(), "no unused revision"); + // A fresh empty table too. + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let revs = s.doc.revisions.len(); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_none()); + assert_eq!(s.doc.revisions.len(), revs); +} + +/// A 2×2 table whose first row is one cell "Title" across both columns; A | B below; caret in A. +fn merged_title_table(lock: Option<&str>) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let at = |r, c| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off: 0 }; + s.sel = Selection { anchor: at(0, 0), focus: at(0, 1) }; + run(&mut s, "table.merge", json!({})); + assert_eq!(table(&s).unwrap().rows[0].cells.len(), 1); + caret_in(&mut s, 0, 0, 0); + run(&mut s, "text.insert", json!({"text": "Title"})); + caret_in(&mut s, 1, 0, 0); + run(&mut s, "text.insert", json!({"text": "A"})); + caret_in(&mut s, 1, 1, 0); + run(&mut s, "text.insert", json!({"text": "B"})); + if let Some(l) = lock { + let p = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; + s.sel = Selection { anchor: p(0), focus: p(2) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); + } + caret_in(&mut s, 1, 0, 0); + s +} + +#[test] +fn delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone() { + // Tracked: only A is marked deleted, not the merged title. + let mut s = merged_title_table(None); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteColumn", json!({})); + assert!(cells_deleted(&s, &[(1, 0)])); + assert!(!cells_deleted(&s, &[(0, 0)]), "the merged title was struck"); + assert!(!cells_deleted(&s, &[(1, 1)])); + // Untracked: a lockDelete control in the title doesn't stop the column going. + let mut s = merged_title_table(Some("lockDelete")); + run(&mut s, "table.deleteColumn", json!({})); + assert_eq!(table(&s).map(|t| t.grid.len()), Some(1)); + assert_eq!(s.doc.control_ranges(StoryRef::Body).len(), 1); +} + +#[test] +fn tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original() { + for id in ["table.deleteRow", "table.deleteColumn"] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + caret_in(&mut s, 0, 0, 2); + run(&mut s, "text.insert", json!({"text": "x"})); + let me = s.author.clone(); + s.author = "Other".into(); + caret_in(&mut s, 0, 1, 2); + run(&mut s, "text.insert", json!({"text": "y"})); + caret_in(&mut s, 1, 0, 2); + run(&mut s, "text.insert", json!({"text": "z"})); + // And a deletion already there. + s.sel = Selection { + anchor: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 0 }, + focus: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 1 }, + }; + run(&mut s, "text.delete", json!({})); + s.author = me; + caret_in(&mut s, 0, 0, 0); + run(&mut s, id, json!({})); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); + assert!(s.doc.revisions.is_empty(), "{id}"); + } +} + +fn tracked_hello() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello world"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "HELLO WORLD"); + s +} + +#[test] +fn tracked_change_case_twice_reads_only_the_live_text() { + let mut s = tracked_hello(); + run(&mut s, "format.changeCase", json!({"mode": "title"})); + assert_eq!(visible(&s), "Hello World"); + // Shift+F3 goes on from UPPERCASE as it does untracked. + let mut plain = Session::new(wordcraft_doc::Document::new()); + run(&mut plain, "text.insert", json!({"text": "HELLO WORLD"})); + run(&mut plain, "select.all", json!({})); + run(&mut plain, "format.changeCase", json!({})); + let mut s = tracked_hello(); + run(&mut s, "format.changeCase", json!({})); + assert_eq!(visible(&s), plain.doc.plain_text(StoryRef::Body)); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hello world"); +} + +#[test] +fn tracked_change_case_handles_sharp_s_and_mixed_formatting() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "straße ab"})); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 3) }; + run(&mut s, "format.bold", json!({})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "STRASSE AB"); + // Each inserted stretch keeps its run's formatting. + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let live: Vec<(String, bool)> = + p.run_ranges().filter(|(_, c)| c.del.is_none()).map(|(r, c)| (p.text[r].to_string(), c.bold == Some(true))).collect(); + let bold: String = live.iter().filter(|x| x.1).map(|x| x.0.as_str()).collect(); + assert_eq!(bold, "STR"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "straße ab"); +} + +#[test] +fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() { + for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, id, json!({})); + caret_in(&mut s, 0, 0, 0); + // Pressed again: the text is already struck, so nothing is removed outright. + let before = s.doc.clone(); + assert!(s.run(id, &json!({})).is_err(), "{id}"); + assert_eq!(s.doc, before, "{id}"); + assert!(s.status.contains("already marked deleted"), "{id}: {}", s.status); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); + } +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 5c36711a9..f1406719f 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-13 · **Change:** trivial (Alt+numpad character codes, #961; previously trivial: text typed just after a field goes outside it, #898; 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-14 · **Change:** trivial (Alt+numpad character codes, #961; previously trivial: Track Changes: table deletes, Change Case and Unlink Fields keep Reject All working and respect locked content controls (#1329); 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 @@ -57,6 +57,9 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp | Paste of several paragraphs (tracking on) | every paragraph mark the paste adds is a tracked insertion; Reject All restores the one paragraph, Accept All keeps the pasted ones | `reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted` | | Backspace at a paragraph start / Delete at a paragraph end (tracking on) | the paragraph mark is marked deleted (still shown); the caret moves before it (Backspace) or past it (Delete). Accept joins the paragraphs, Reject keeps them. A mark you inserted yourself is just removed | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::tracked_backspace_and_delete_mark_a_paragraph_break_deleted` | | 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` | +| 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` | ## Editing keys @@ -191,6 +194,9 @@ Word. | 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-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) | | 2026-10-12 | 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) | | 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) | | 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) | From 52e56f8d76e8226769afa4a0dbb011525cba535c Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:27:23 +0200 Subject: [PATCH 080/199] PDF export: All Caps text extracts correctly (#1473) (#1509) * #1473: WIP * Co-Authored-By: Claude Opus 5.5 * #1473: list labels and field results in caps extract as shown Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/layout/src/display.rs | 12 ++++++- crates/layout/src/para.rs | 17 ++++++++-- crates/pdf/src/tests.rs | 61 ++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +-- 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 68835cacf..1c2aee301 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-14 · **Change:** 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: 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; 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: 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. @@ -152,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | +| 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) | | 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | | 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | @@ -327,6 +328,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) | | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | +| 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) | | 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | | 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 2b16fa7b4..c83ab7613 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -763,13 +763,23 @@ fn lines( if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _, _)| *i) && let Some((_, s, r)) = pl.shown.get(i) { - text.push_str(s); + // All Caps field results read as the capitals drawn (#1473). `r` holds + // byte ranges into `s`, so a character whose capital has another UTF-8 + // length keeps its own form and every range still lands where it did. + text.extend(s.chars().map(|ch| { + let up = crate::para::caps_char(&st.rc, ch); + if up.len_utf8() == ch.len_utf8() { up } else { ch } + })); shown = Some(r); } else if let Some(p) = para { let t = p.text.get(c.start..c.end).unwrap_or(""); // Symbol-font characters (Wingdings…) read as the Unicode symbols drawn. if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) { text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t)); + } else if st.rc.caps || st.rc.small_caps { + // All Caps and Small Caps read as the capitals drawn (#1473), mapped + // as the shaper maps them, so a glyph never stands for two cases. + text.extend(t.chars().map(|ch| crate::para::caps_char(&st.rc, ch))); } else { text.push_str(t); } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index a67fe3a43..c3aed221a 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -514,6 +514,18 @@ impl SegKey { } } +/// The capital All Caps and Small Caps draw for `c`: one character in, one out (ß draws as S). +fn upper_char(c: char) -> char { + c.to_uppercase().next().unwrap_or(c) +} + +/// The character drawn for `c` in a run formatted `rc`: All Caps capitalises every letter, +/// Small Caps the lowercase ones (as the shaper draws them). The PDF text layer uses it so +/// the text read back is the text shown (#1473). +pub(crate) fn caps_char(rc: &ResolvedChar, c: char) -> char { + if rc.caps || (rc.small_caps && c.is_lowercase()) { upper_char(c) } else { c } +} + /// Combining marks and joiners (bidi classes NSM, BN: harakat, ZWNJ, ZWJ) belong to the /// character before them: same font, same shaping run, so Arabic joining isn't broken. fn is_mark(c: char) -> bool { @@ -817,7 +829,7 @@ impl<'a> Builder<'a> { if d != c { d } else if upper { - c.to_uppercase().next().unwrap_or(c) + upper_char(c) } else { c } @@ -1467,7 +1479,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() { glyphs.push(Glyph { dx: g.dx + x, ..*g }); } - let chars = shown(text.get(c.start..c.end).unwrap_or("")); + // A label in an All Caps paragraph reads as the capitals drawn (#1473). + let chars: String = shown(text.get(c.start..c.end).unwrap_or("")).chars().map(|ch| caps_char(&rc, ch)).collect(); match runs.last_mut() { Some(r) if r.style == c.style => { r.glyphs.end = glyphs.len(); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 49c48a199..2b434239a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1024,6 +1024,67 @@ fn internal_links_export_in_linear_time() { assert!(ratio < 9.0, "4x the paragraphs and links took {ratio:.1}x as long (quadratic would be ~16x)"); } +#[test] +fn all_caps_and_small_caps_text_extracts_as_displayed() { + // #1473: All Caps / Small Caps text is drawn with capital glyphs, so it extracts in capitals + // (as Word's PDFs do), and later text without caps keeps its own case: a capital glyph + // first drawn for a lowercase letter must not make every later capital read as lowercase. + let mut d = Document::new(); + d.styles.upsert(wordcraft_doc::Style { + id: "CapsPara".into(), + name: "Caps Para".into(), + kind: wordcraft_doc::StyleKind::Paragraph, + chr: CharProps { caps: Some(true), ..Default::default() }, + ..Default::default() + }); + let caps = CharProps { caps: Some(true), ..Default::default() }; + let small = CharProps { small_caps: Some(true), ..Default::default() }; + d.body = vec![ + para_block(Paragraph::with_text("Employment Period. Your employment will begin on the date below.", CharProps::default()).styled("CapsPara")), + para_block(Paragraph::with_text("Direct caps on the run: Mixed Case Words.", caps.clone())), + para_block(Paragraph::with_text("Plain text, no caps: Employment Period.", CharProps::default())), + para_block(Paragraph::with_text("Small caps line: Quiet Words.", small)), + para_block(Paragraph::with_text("Straße", caps)), + para_block(Paragraph::with_text("Plain again: Straße, Quiet Period.", CharProps::default())), + ]; + let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat(); + let text = squash(&text); + for line in [ + "EMPLOYMENT PERIOD. YOUR EMPLOYMENT WILL BEGIN ON THE DATE BELOW.", + "DIRECT CAPS ON THE RUN: MIXED CASE WORDS.", + "Plain text, no caps: Employment Period.", + "SMALL CAPS LINE: QUIET WORDS.", + "Plain again: Straße, Quiet Period.", + ] { + assert!(text.contains(line), "missing {line:?} in {text:?}"); + } + // An All Caps ß is drawn today as one S glyph, so it reads as that S: the text layer follows + // the glyph drawn. Drawing it as "SS", as Word does, is a separate display issue. + assert!(text.contains("STRASE"), "{text:?}"); +} + +#[test] +fn caps_list_labels_extract_as_displayed() { + // #1473: a lowercase-letter list label in an All Caps paragraph is drawn with capital glyphs; + // it reads as those capitals, and a later plain capital A still reads as "A". + let mut d = Document::new(); + d.styles.upsert(wordcraft_doc::Style { + id: "CapsPara".into(), + name: "Caps Para".into(), + kind: wordcraft_doc::StyleKind::Paragraph, + chr: CharProps { caps: Some(true), ..Default::default() }, + ..Default::default() + }); + let list = d.numbering.add_list(ListKind::Numbered); + let mut item = Paragraph::with_text("item", CharProps::default()).styled("CapsPara"); + item.props.numbering = Some(NumRef { num: list, level: 1 }); + d.body = vec![para_block(item), para_block(Paragraph::with_text("Plain Apple.", CharProps::default()))]; + let text = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat()); + assert!(text.contains("A."), "the label reads as the capital drawn: {text:?}"); + assert!(!text.contains("a."), "no lowercase label: {text:?}"); + assert!(text.contains("Plain Apple."), "a plain capital A keeps its case: {text:?}"); +} + /// The reporter's four paragraphs from #1383 (Hebrew, Arabic and mixed right-to-left paragraphs, /// and a Latin one), then right-to-left words with a lam-alef ligature and with vowel marks. fn rtl_sample() -> Document { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 618da1547..811881cbc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (ODT sections on import, #1379; previously trivial: PDF export: Hebrew and Arabic text extracts as whole words, #1383; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: ODT sections on import, #1379; previously trivial: PDF export: Hebrew and Arabic text extracts as whole words, #1383; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -22,7 +22,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.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. 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 | -| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | +| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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 or encrypted files are dropped or refused. No writer | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379). No footnotes, comments, tracked changes, fields or RTL | @@ -169,6 +169,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | +| 2026-10-14 | trivial | PDF export: All Caps and Small Caps text extracts in capitals, as displayed, and no longer turns later capitals in uncapitalised text lowercase (#1473) | | 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) | | 2026-10-14 | trivial | PDF export writes right-to-left runs' glyphs in visual order, so Chrome, Edge (PDFium) and poppler extract Hebrew and Arabic as whole words (vowelled words keep their letters in order but may extract with spaces inside), and Ctrl+F finds them (#1383). `/ReversedChars` (for strict file-order readers) covers plain runs and vowelled runs whose marks split into their own glyphs, not runs with ligatures or mixed directions | | 2026-10-14 | trivial | HTML: `` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) | From 03ba9e483fb405633cb466112c68924fb2d89df9 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:27:42 +0200 Subject: [PATCH 081/199] Web: New/Open waits for Save before discarding the document (#1315) (#1510) * #1445: Save in the close prompt that can't write the file says so instead of looking like Cancel Co-Authored-By: Claude Opus 5.5 * #1445: a failed save from the close prompt brings the prompt back with the reason, Save As included Co-Authored-By: Claude Opus 5.5 * #1315: web Save before New/Open waits for the save picker; cancel or failure keeps the document Co-Authored-By: Claude Opus 5.5 * #1315: overlapping saves hold the replacement; cancel and waiting messages Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 * Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 8 +- apps/wordcraft-web/src/web.rs | 69 ++---- crates/ui-egui/src/async_saves.rs | 108 ++++++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 318 +++++++++++++++++++++++++++- docs/ui-parity.md | 3 +- 24 files changed, 486 insertions(+), 60 deletions(-) create mode 100644 crates/ui-egui/src/async_saves.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1c2aee301..29e2f917e 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; 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: 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: the web's Save before New/Open waits for the save picker, #1315; 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: 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. @@ -153,6 +153,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) | +| 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) | +| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | | 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | @@ -166,7 +168,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page | | 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole | | 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. | -| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) | | 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height | | 2026-10-14 | A paragraph that runs on into the next column or onto the next page wraps around the shapes there (#1374): its continued lines are broken again for that column's wrap areas instead of running through a shape there (or around one left on the page before); the lines before, and the paragraph's own floating objects, stay where they were. Paragraphs with nothing new to wrap around are not laid out again | @@ -329,6 +330,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) | | 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) | | 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) | +| 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) | +| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | | 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | @@ -341,7 +344,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) | | 2026-10-13 | trivial | Progress log: space after a page break | | 2026-10-13 | trivial | Progress log: table row margins and splitting | -| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) | | 2026-10-14 | trivial | Progress log: continued paragraphs wrap around the next column's shapes (#1374) | | 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index 36a731478..51e4e2705 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -1,11 +1,12 @@ //! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe //! web runner. -use std::cell::{Cell, RefCell}; +use std::cell::Cell; use std::rc::Rc; use wasm_bindgen::JsCast as _; use wordcraft_engine::Session; +use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome}; use wordcraft_ui_egui::{Inbox, Services, WordApp}; const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; @@ -27,7 +28,7 @@ pub fn start() { return; }; let dirty = Rc::new(Cell::new(false)); - let saves = Rc::new(PickerSaves::default()); + let saves = AsyncSaves::default(); if let Err(e) = guard_unload(dirty.clone(), saves.clone()) { log::error!("no unsaved-changes guard: {e}"); } @@ -67,7 +68,7 @@ pub fn start() { if !sample { app.start_screen(); } - Ok(Box::new(WebShell { app, inbox, saves })) + Ok(Box::new(WebShell { app, inbox })) }), ) .await; @@ -89,38 +90,6 @@ fn query() -> String { struct WebShell { app: WordApp, inbox: Inbox, - saves: Rc, -} - -/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks -/// the document saved as soon as `download` returns, so a cancelled or failed picker save must -/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight. -#[derive(Default)] -struct PickerSaves { - /// Picker saves started and not yet written and closed. - pending: Cell, - /// Picker saves that didn't write the file: (name, error, or `None` when cancelled). - unsaved: RefCell)>>, -} - -impl WebShell { - /// A picker save that didn't happen leaves the document with unsaved changes. - fn settle_picker_saves(&mut self) { - let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut()); - for (name, error) in unsaved { - // Only a save in a format that keeps everything marked the document saved. - if wordcraft_ui_egui::keeps_everything(&name) { - self.app.session.dirty = true; - } - if let Some(error) = error { - let msg = wordcraft_ui_egui::i18n::fmt( - wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"), - &[("name", &name), ("error", &error)], - ); - self.app.status(msg); - } - } - } } impl eframe::App for WebShell { @@ -140,7 +109,6 @@ impl eframe::App for WebShell { } }); } - self.settle_picker_saves(); self.app.logic(ctx); } @@ -201,7 +169,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St } /// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]). -fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>, saves: Rc) -> Services { +fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>, saves: AsyncSaves) -> Services { let open_inbox = inbox.clone(); let save_ctx = ctx.clone(); Services { @@ -228,6 +196,7 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>, saves: Rc

Box Option> { /// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own /// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save. -fn guard_unload(dirty: Rc>, saves: Rc) -> Result<(), String> { +fn guard_unload(dirty: Rc>, saves: AsyncSaves) -> Result<(), String> { let window = web_sys::window().ok_or("no window")?; let on_unload = wasm_bindgen::closure::Closure::::new(move |e: web_sys::BeforeUnloadEvent| { // A save still in the browser's picker hasn't written anything yet. - if dirty.get() || saves.pending.get() > 0 { + if dirty.get() || saves.pending() > 0 { e.prevent_default(); // Older browsers ask only when `returnValue` is set. e.set_return_value("unsaved changes"); @@ -266,7 +235,7 @@ fn guard_unload(dirty: Rc>, saves: Rc) -> Result<(), Str } /// Trigger a browser save dialog or download of `bytes` named after the last component of `path`. -fn download(saves: &Rc, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> { +fn download(saves: &AsyncSaves, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into()); let window = web_sys::window().ok_or("no window")?; @@ -288,7 +257,7 @@ fn download(saves: &Rc, ctx: &egui::Context, path: &str, bytes: &[u } fn trigger_save_file_picker( - saves: &Rc, + saves: &AsyncSaves, ctx: &egui::Context, window: &web_sys::Window, picker_fn: &js_sys::Function, @@ -332,18 +301,19 @@ fn trigger_save_file_picker( let saves = saves.clone(); let ctx = ctx.clone(); let name = name.to_string(); - saves.pending.set(saves.pending.get().saturating_add(1)); + // The app holds back a Save Changes prompt's New/Open until this ticket ends (#1315). + let ticket = saves.start(); wasm_bindgen_futures::spawn_local(async move { // The document counts as saved only once the file is written and closed. - let unsaved = match wasm_bindgen_futures::JsFuture::from(prom).await { + let outcome = match wasm_bindgen_futures::JsFuture::from(prom).await { Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await { Ok(()) => { log::info!("saved {name} through the save picker"); - None + SaveOutcome::Saved } Err(e) => { log::error!("couldn't write {name}: {e}"); - Some(Some(e)) + SaveOutcome::Failed(e) } }, Err(e) => { @@ -351,17 +321,14 @@ fn trigger_save_file_picker( js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default(); if err_name == "AbortError" { log::debug!("save picker cancelled"); - Some(None) + SaveOutcome::Cancelled } else { log::error!("showSaveFilePicker error: {e:?}"); - Some(Some(format!("{e:?}"))) + SaveOutcome::Failed(format!("{e:?}")) } } }; - if let Some(error) = unsaved { - saves.unsaved.borrow_mut().push((name, error)); - } - saves.pending.set(saves.pending.get().saturating_sub(1)); + saves.finish(ticket, outcome); ctx.request_repaint(); }); Ok(()) diff --git a/crates/ui-egui/src/async_saves.rs b/crates/ui-egui/src/async_saves.rs new file mode 100644 index 000000000..a3c054e79 --- /dev/null +++ b/crates/ui-egui/src/async_saves.rs @@ -0,0 +1,108 @@ +//! Saves that finish after the save command has returned (#1315). +//! +//! In the browser, Save hands the bytes to the save picker; the user picks a place (or cancels) +//! and the file is written and closed later, long after `download` returned. The host starts a +//! ticket for such a save ([`AsyncSaves::start`]) while `download` runs and reports how it ended +//! ([`AsyncSaves::finish`]); the app reads the outcomes each frame. A "Save changes?" prompt +//! whose Save went to the picker holds its New/Open back until its ticket ends: it runs on +//! success, and on a cancel or failure the document stays open, still unsaved, and the prompt +//! comes back with the reason. +//! +//! Plain `Rc>` state: no locks, no borrow that could fail. + +use std::cell::Cell; +use std::rc::Rc; + +/// How a save that finished later ended. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SaveOutcome { + /// The file is written and closed. + Saved, + /// The user cancelled the picker; nothing was written. + Cancelled, + /// The picker, the write or the close failed (the reason). + Failed(String), +} + +#[derive(Default)] +struct State { + next: u64, + /// The ticket the current `download` call started, not yet claimed by the app. + started: Option, + /// Tickets started and not finished. + pending: Vec, + /// Tickets finished and not yet read by the app. + finished: Vec<(u64, SaveOutcome)>, +} + +/// Saves in flight, shared between the host's `download` hook and the app (a cheap clone). +#[derive(Clone, Default)] +pub struct AsyncSaves(Rc>); + +impl AsyncSaves { + fn with(&self, f: impl FnOnce(&mut State) -> R) -> R { + let mut state = self.0.take(); + let r = f(&mut state); + self.0.set(state); + r + } + + /// The host's `download` hook: the save it is starting finishes later. Returns its ticket, + /// to pass to [`Self::finish`]. + pub fn start(&self) -> u64 { + self.with(|s| { + s.next = s.next.wrapping_add(1); + let ticket = s.next; + s.started = Some(ticket); + s.pending.push(ticket); + ticket + }) + } + + /// The save `ticket` ended. An unknown or already finished ticket is ignored. + pub fn finish(&self, ticket: u64, outcome: SaveOutcome) { + self.with(|s| { + if let Some(i) = s.pending.iter().position(|&t| t == ticket) { + s.pending.swap_remove(i); + s.finished.push((ticket, outcome)); + } + }); + } + + /// Saves started and not finished (the browser's leave-page guard warns while there are any). + pub fn pending(&self) -> usize { + self.with(|s| s.pending.len()) + } + + /// The ticket the last `download` call started, if it started one (claimed once). + pub(crate) fn take_started(&self) -> Option { + self.with(|s| s.started.take()) + } + + /// The saves that ended since the last call, oldest first. + pub(crate) fn take_finished(&self) -> Vec<(u64, SaveOutcome)> { + self.with(|s| std::mem::take(&mut s.finished)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tickets_start_finish_and_are_read_once() { + let saves = AsyncSaves::default(); + let a = saves.start(); + let b = saves.start(); + assert_ne!(a, b); + assert_eq!(saves.take_started(), Some(b)); + assert_eq!(saves.take_started(), None); + assert_eq!(saves.pending(), 2); + saves.finish(a, SaveOutcome::Cancelled); + saves.finish(a, SaveOutcome::Saved); + saves.finish(999, SaveOutcome::Saved); + assert_eq!(saves.pending(), 1); + assert_eq!(saves.take_finished(), vec![(a, SaveOutcome::Cancelled)]); + assert!(saves.take_finished().is_empty()); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1cf83e0bb..e867f457e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2467,6 +2467,8 @@ Leave out the date and time of comments and tracked changes when saving Без дата и час на коментарите и проследените промени при запазване The document you are editing keeps the names and dates; only the saved file leaves them out. Документът, който редактирате, запазва имената и датите; само записаният файл ги пропуска. Privacy Settings Текущи настройки за поверителност + Waiting for the save to finish… Изчакване запазването да приключи… + Saving {name} was cancelled, so it is still open and nothing was lost. Запазването на {name} е отменено. Документът остава отворен и нищо не е загубено. Paper Хартия Paper size: Размер на хартията: Custom size Потребителски размер diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e58338a8a..06b8341ab 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2092,6 +2092,8 @@ Leave out the date and time of comments and tracked changes when saving Při ukládání vynechat datum a čas komentářů a sledovaných změn The document you are editing keeps the names and dates; only the saved file leaves them out. Upravovaný dokument si jména a data ponechá; vynechá je jen uložený soubor. Privacy Settings Aktuální nastavení soukromí + Waiting for the save to finish… Čeká se na dokončení ukládání… + Saving {name} was cancelled, so it is still open and nothing was lost. Ukládání {name} bylo zrušeno, dokument zůstává otevřený a nic se neztratilo. Paper Papír Paper size: Velikost papíru: Custom size Vlastní velikost diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1da97e88b..38d822dc8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2086,6 +2086,8 @@ Leave out the date and time of comments and tracked changes when saving Beim Speichern Datum und Uhrzeit von Kommentaren und nachverfolgten Änderungen weglassen The document you are editing keeps the names and dates; only the saved file leaves them out. Das bearbeitete Dokument behält Namen und Daten; nur die gespeicherte Datei lässt sie weg. Privacy Settings Aktuelle Datenschutzeinstellungen + Waiting for the save to finish… Warten, bis das Speichern abgeschlossen ist… + Saving {name} was cancelled, so it is still open and nothing was lost. Das Speichern von {name} wurde abgebrochen. Das Dokument ist weiterhin geöffnet und nichts ist verloren gegangen. Paper Papier Paper size: Papierformat: Custom size Benutzerdefiniertes Format diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8e4970d93..c2faf8e26 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2453,6 +2453,8 @@ preview Title Título Leave out the date and time of comments and tracked changes when saving Omitir la fecha y la hora de los comentarios y los cambios controlados al guardar The document you are editing keeps the names and dates; only the saved file leaves them out. El documento que editas conserva los nombres y las fechas; solo el archivo guardado los omite. Privacy Settings Configuración de privacidad actual + Waiting for the save to finish… Esperando a que termine de guardarse… + Saving {name} was cancelled, so it is still open and nothing was lost. Se canceló el guardado de {name}; sigue abierto y no se perdió nada. Paper Papel Paper size: Tamaño del papel: Custom size Tamaño personalizado diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index f5a44f431..bde7bced7 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2121,6 +2121,8 @@ Leave out the date and time of comments and tracked changes when saving Jäta salvestamisel välja kommentaaride ja jälitatud muudatuste kuupäev ja kellaaeg The document you are editing keeps the names and dates; only the saved file leaves them out. Redigeeritav dokument säilitab nimed ja kuupäevad; need jäetakse välja ainult salvestatud failist. Privacy Settings Praegused privaatsussätted + Waiting for the save to finish… Ootan salvestamise lõppu… + Saving {name} was cancelled, so it is still open and nothing was lost. Faili {name} salvestamine tühistati, see on endiselt avatud ja midagi ei läinud kaotsi. Paper Paber Paper size: Paberi suurus: Custom size Kohandatud suurus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index edb6b4f9e..dcf05de67 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2095,6 +2095,8 @@ Leave out the date and time of comments and tracked changes when saving Omettre la date et l’heure des commentaires et des modifications suivies à l’enregistrement The document you are editing keeps the names and dates; only the saved file leaves them out. Le document en cours de modification garde les noms et les dates ; seul le fichier enregistré les omet. Privacy Settings Paramètres de confidentialité actuels + Waiting for the save to finish… En attente de la fin de l'enregistrement… + Saving {name} was cancelled, so it is still open and nothing was lost. L'enregistrement de {name} a été annulé : le document reste ouvert et rien n'a été perdu. Paper Papier Paper size: Taille du papier : Custom size Taille personnalisée diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c7ebd6893..bf8a069d2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2475,6 +2475,8 @@ Leave out the date and time of comments and tracked changes when saving Mentéskor a megjegyzések és a követett módosítások dátumának és idejének elhagyása The document you are editing keeps the names and dates; only the saved file leaves them out. A szerkesztett dokumentum megtartja a neveket és a dátumokat; csak a mentett fájlból maradnak ki. Privacy Settings Jelenlegi adatvédelmi beállítások + Waiting for the save to finish… Várakozás a mentés befejezésére… + Saving {name} was cancelled, so it is still open and nothing was lost. A(z) {name} mentése megszakadt, így továbbra is nyitva van, és semmi sem veszett el. Paper Papír Paper size: Papírméret: Custom size Egyéni méret diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1d78bb46d..cb0605942 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2462,6 +2462,8 @@ preview Title Esempio Leave out the date and time of comments and tracked changes when saving Ometti data e ora di commenti e revisioni al salvataggio The document you are editing keeps the names and dates; only the saved file leaves them out. Il documento che stai modificando conserva nomi e date; solo il file salvato li omette. Privacy Settings Impostazioni di privacy attuali + Waiting for the save to finish… In attesa del termine del salvataggio… + Saving {name} was cancelled, so it is still open and nothing was lost. Il salvataggio di {name} è stato annullato: il documento è ancora aperto e non è andato perso nulla. Paper Carta Paper size: Dimensioni carta: Custom size Dimensioni personalizzate diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8896a814b..01a055a69 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2087,6 +2087,8 @@ Leave out the date and time of comments and tracked changes when saving 保存時にコメントと変更履歴の日時を含めない The document you are editing keeps the names and dates; only the saved file leaves them out. 編集中のドキュメントには名前と日時が残ります。保存したファイルにだけ含まれません。 Privacy Settings 現在のプライバシー設定 + Waiting for the save to finish… 保存が完了するのを待っています… + Saving {name} was cancelled, so it is still open and nothing was lost. {name} の保存はキャンセルされました。文書は開いたままで、何も失われていません。 Paper 用紙 Paper size: 用紙サイズ: Custom size ユーザー定義サイズ diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4c6e4f088..30f043461 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2471,6 +2471,8 @@ preview Title Tytuł Leave out the date and time of comments and tracked changes when saving Pomiń datę i godzinę komentarzy i śledzonych zmian przy zapisywaniu The document you are editing keeps the names and dates; only the saved file leaves them out. Edytowany dokument zachowuje nazwiska i daty; pomija je tylko zapisany plik. Privacy Settings Bieżące ustawienia prywatności + Waiting for the save to finish… Oczekiwanie na zakończenie zapisywania… + Saving {name} was cancelled, so it is still open and nothing was lost. Zapisywanie pliku {name} zostało anulowane. Dokument jest nadal otwarty i nic nie zostało utracone. Paper Papier Paper size: Rozmiar papieru: Custom size Rozmiar niestandardowy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1a0c9a4b7..8ff415f17 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2099,6 +2099,8 @@ Leave out the date and time of comments and tracked changes when saving Omitir a data e a hora dos comentários e das alterações controladas ao salvar The document you are editing keeps the names and dates; only the saved file leaves them out. O documento que você está editando mantém os nomes e as datas; só o arquivo salvo os omite. Privacy Settings Configurações de privacidade atuais + Waiting for the save to finish… Aguardando o término do salvamento… + Saving {name} was cancelled, so it is still open and nothing was lost. O salvamento de {name} foi cancelado; ele continua aberto e nada foi perdido. Paper Papel Paper size: Tamanho do papel: Custom size Tamanho personalizado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 993b27c14..b5456c933 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2477,6 +2477,8 @@ preview Title Пример Leave out the date and time of comments and tracked changes when saving Не сохранять дату и время примечаний и отслеживаемых изменений The document you are editing keeps the names and dates; only the saved file leaves them out. Редактируемый документ сохраняет имена и даты; их не будет только в сохранённом файле. Privacy Settings Текущие параметры конфиденциальности + Waiting for the save to finish… Ожидание завершения сохранения… + Saving {name} was cancelled, so it is still open and nothing was lost. Сохранение {name} отменено. Документ по-прежнему открыт, ничего не потеряно. Paper Бумага Paper size: Размер бумаги: Custom size Другой размер diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 211af3972..3d8df0657 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2097,6 +2097,8 @@ Leave out the date and time of comments and tracked changes when saving Izostavi datum i vreme komentara i praćenih izmena pri čuvanju The document you are editing keeps the names and dates; only the saved file leaves them out. Dokument koji uređujete zadržava imena i datume; izostavlja ih samo sačuvana datoteka. Privacy Settings Trenutna podešavanja privatnosti + Waiting for the save to finish… Čeka se da se čuvanje završi… + Saving {name} was cancelled, so it is still open and nothing was lost. Čuvanje {name} je otkazano. Dokument je i dalje otvoren i ništa nije izgubljeno. Paper Papir Paper size: Veličina papira: Custom size Prilagođena veličina diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b89e800a0..592baa1b2 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2101,6 +2101,8 @@ Leave out the date and time of comments and tracked changes when saving Изостави датум и време коментара и праћених измена при чувању The document you are editing keeps the names and dates; only the saved file leaves them out. Документ који уређујете задржава имена и датуме; изоставља их само сачувана датотека. Privacy Settings Тренутна подешавања приватности + Waiting for the save to finish… Чека се да се чување заврши… + Saving {name} was cancelled, so it is still open and nothing was lost. Чување {name} је отказано. Документ је и даље отворен и ништа није изгубљено. Paper Папир Paper size: Величина папира: Custom size Прилагођена величина diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d63c09cee..c80fe40f2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2476,6 +2476,8 @@ preview Title Örnek Leave out the date and time of comments and tracked changes when saving Kaydederken açıklamaların ve izlenen değişikliklerin tarih ve saatini dahil etme The document you are editing keeps the names and dates; only the saved file leaves them out. Düzenlediğiniz belge adları ve tarihleri korur; yalnızca kaydedilen dosya bunları içermez. Privacy Settings Geçerli gizlilik ayarları + Waiting for the save to finish… Kaydetme işleminin bitmesi bekleniyor… + Saving {name} was cancelled, so it is still open and nothing was lost. {name} kaydetme işlemi iptal edildi; belge hâlâ açık ve hiçbir şey kaybolmadı. Paper Kağıt Paper size: Kağıt boyutu: Custom size Özel boyut diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 20d4c5bed..4bfb71e4d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2099,6 +2099,8 @@ Leave out the date and time of comments and tracked changes when saving Не зберігати дату й час приміток і відстежуваних змін The document you are editing keeps the names and dates; only the saved file leaves them out. Документ, який ви редагуєте, зберігає імена й дати; їх не буде лише у збереженому файлі. Privacy Settings Поточні налаштування конфіденційності + Waiting for the save to finish… Очікування завершення збереження… + Saving {name} was cancelled, so it is still open and nothing was lost. Збереження {name} скасовано. Документ досі відкритий, нічого не втрачено. Paper Папір Paper size: Розмір паперу: Custom size Власний розмір diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e1848ba3f..45fcb708e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2524,3 +2524,7 @@ Paper Giấy Paper size: Khổ giấy: The margins don't fit on a page this size; make the margins smaller first. Lề không vừa với trang có kích thước này; hãy thu nhỏ lề trước. + +# Web Save before New/Open (#1510) + Saving {name} was cancelled, so it is still open and nothing was lost. Đã hủy lưu {name}, nên tài liệu vẫn mở và không mất gì. + Waiting for the save to finish… Đang chờ lưu xong… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7751d040b..6566f76b1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2099,6 +2099,8 @@ Leave out the date and time of comments and tracked changes when saving 保存时不包含批注和修订的日期与时间 The document you are editing keeps the names and dates; only the saved file leaves them out. 正在编辑的文档保留姓名和日期;只有保存的文件中不包含它们。 Privacy Settings 当前隐私设置 + Waiting for the save to finish… 正在等待保存完成… + Saving {name} was cancelled, so it is still open and nothing was lost. 已取消保存 {name},文档仍处于打开状态,没有丢失任何内容。 Paper 纸张 Paper size: 纸张大小: Custom size 自定义大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e350e75db..19091d6de 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2088,6 +2088,8 @@ Leave out the date and time of comments and tracked changes when saving 儲存時不包含註解和追蹤修訂的日期與時間 The document you are editing keeps the names and dates; only the saved file leaves them out. 正在編輯的文件會保留名稱和日期;只有儲存的檔案不包含它們。 Privacy Settings 目前的隱私設定 + Waiting for the save to finish… 正在等待儲存完成… + Saving {name} was cancelled, so it is still open and nothing was lost. 已取消儲存 {name},文件仍保持開啟,沒有遺失任何內容。 Paper 紙張 Paper size: 紙張大小: Custom size 自訂大小 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38a34bfba..1a9fd0a72 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ macro_rules! tl { pub mod a11y; pub mod a11y_pane; +pub mod async_saves; pub mod autoscroll; pub mod backstage; pub mod canvas; @@ -114,6 +115,10 @@ pub struct Services { /// Web: hand bytes to the browser as a download. An error means no download started (the /// browser can't tell the page whether the user then kept the file). pub download: Option Result<(), String>>>, + /// Web: saves that `download` started and that finish later (the browser's save picker, + /// #1315). The hook starts a ticket for such a save and reports how it ended; until then the + /// document's save isn't confirmed, and a New/Open held back by the Save Changes prompt waits. + pub async_saves: Option, /// Web: told whether the document has unsaved changes after each pass, for the browser's /// leave-page guard (`beforeunload` runs between frames and can't ask the app). pub on_dirty: Option>, @@ -158,6 +163,38 @@ pub struct Services { pub make_default_app: Option Result>>, } +/// How Save in the Save Changes prompt went. +enum PromptSave { + /// Written, keeping everything: the held-back command may run. + Saved, + /// Not saved (a format that doesn't keep everything); the command is cancelled. + NotSaved, + /// Web: the browser's save picker finishes later; its ticket ([`async_saves`]). + Later(u64), +} + +/// Web: a save the browser's picker hasn't finished ([`async_saves`]). +struct SaveInFlight { + ticket: u64, + /// The file name it saves as. + name: String, + /// The document it saved ([`Session::document_id`]). + document: u64, + /// Whether it marked the document saved (a Save in a format that keeps everything). + marked_saved: bool, +} + +/// Web: the command a Save Changes prompt holds back until its picker save ends. +struct AwaitingSave { + ticket: u64, + then: String, + params: Value, + /// The document the prompt asked about. + document: u64, + /// Its revision once the save started: a later edit isn't in that save. + rev: u64, +} + /// What [`Services::make_default_app`] did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DefaultApp { @@ -462,6 +499,10 @@ pub struct WordApp { /// there. A file that was merely opened isn't rewritten until the user saves it: saving drops /// whatever WordCraft can't represent (content controls, charts, macros…). autosave_path: Option, + /// Web: saves the browser's picker hasn't finished yet ([`async_saves`], #1315). + saves_in_flight: Vec, + /// Web: the command a Save Changes prompt held back until its picker save ends (#1315). + awaiting_save: Option, /// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]). file_dialog: Option, /// The built-in file browser, when it is open, and the folder it showed last (#576). @@ -556,6 +597,8 @@ impl WordApp { read_aloud_at: None, read_aloud_error: None, autosave_path: None, + saves_in_flight: Vec::new(), + awaiting_save: None, change_picture_target: None, recipient_list_pending: false, font_generation: wordcraft_fonts::FontDb::global().generation(), @@ -662,7 +705,7 @@ impl WordApp { self.dialog = dialogs::Dialog::open(name, self); return Ok(json!({"pending": name})); } - if self.session.dirty && discards_document(id, ¶ms) { + if (self.session.dirty || self.save_unconfirmed()) && discards_document(id, ¶ms) { let name = self.prompt_name(); self.dialog = Some(dialogs::Dialog::SaveChanges { name, @@ -758,6 +801,11 @@ impl WordApp { if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None }; let opts = wordcraft_engine::cmd::file::export_options(&self.session, ¶ms); let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password, &opts)?; + // A ticket left over from an earlier call isn't this save's. + let saves = self.services.async_saves.clone(); + if let Some(saves) = &saves { + let _ = saves.take_started(); + } if let Some(d) = &self.services.download && let Err(e) = d(&name, &bytes) { @@ -768,9 +816,17 @@ impl WordApp { } // An export is a copy, and so is a save in a format that doesn't keep everything (a // page image, plain text): the document itself still has unsaved changes. - if matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name) { + let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name); + if marked_saved { self.session.dirty = false; } + // The browser's picker writes the file later (#1315): until it says how that went, + // the document's save isn't confirmed. + if let Some(ticket) = saves.as_ref().and_then(async_saves::AsyncSaves::take_started) { + let document = self.session.document_id(); + self.saves_in_flight.push(SaveInFlight { ticket, name: name.clone(), document, marked_saved }); + return Ok(json!({"downloaded": name, "bytes": bytes.len(), "pendingSave": ticket})); + } return Ok(json!({"downloaded": name, "bytes": bytes.len()})); } let document = self.session.document_id(); @@ -861,7 +917,15 @@ impl WordApp { }; } SaveChoice::Save => match self.save_for_prompt() { - Ok(saved) => saved, + Ok(PromptSave::Saved) => true, + Ok(PromptSave::NotSaved) => false, + // The browser's picker finishes later: the command waits for it (#1315). + Ok(PromptSave::Later(ticket)) => { + let rev = self.session.rev(); + self.awaiting_save = Some(AwaitingSave { ticket, then, params, document, rev }); + self.status(tl!("Waiting for the save to finish…")); + return Ok(json!({"pending": "save"})); + } Err(error) => return Err(self.save_failed(&error, then, params, document)), }, }; @@ -893,10 +957,24 @@ impl WordApp { /// the command is cancelled and the document stays. A save that fails (no permission, the /// file locked by another app, a missing folder) is the save's error ([`Self::save_failed`] /// tells the user). - fn save_for_prompt(&mut self) -> Result { + /// In the browser the save picker may write the file later ([`PromptSave::Later`], #1315). + fn save_for_prompt(&mut self) -> Result { let v = self.execute("file.save", json!({}))?; let saved = v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(); - Ok(self.safely_saved(saved)) + if !self.safely_saved(saved) { + return Ok(PromptSave::NotSaved); + } + Ok(match v.get("pendingSave").and_then(Value::as_u64) { + Some(ticket) => PromptSave::Later(ticket), + None => PromptSave::Saved, + }) + } + + /// Web: a save of this document is still in the browser's picker (#1315). The document + /// looks saved, but nothing is written yet, so New/Open ask first. + fn save_unconfirmed(&self) -> bool { + let document = self.session.document_id(); + self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == document) } /// The document's name as the Save Changes prompt shows it: its file's name, else its title. @@ -912,13 +990,80 @@ impl WordApp { let name = self.prompt_name(); let message = i18n::fmt(tl!("Couldn't save {name}, so it is still open and nothing was lost: {error}"), &[("name", &name), ("error", error)]); log::error!("save from the Save Changes prompt failed: {error}"); + self.prompt_again(message, then, params, document) + } + + /// The Save Changes prompt comes back with `message` (also on the status bar), the held-back + /// command still waiting, if the document is still the one it asked about. Returns `message`. + fn prompt_again(&mut self, message: String, then: String, params: Value, document: u64) -> String { self.status(message.clone()); if document == self.session.document_id() { + let name = self.prompt_name(); self.dialog = Some(dialogs::Dialog::SaveChanges { name, then, params, document, message: message.clone() }); } message } + /// Read how the saves that finished later ended (#1315). Called every frame. + /// A save that didn't write the file leaves the document it was for unsaved again (only that + /// document: one that has replaced it since is left alone). The command a Save Changes prompt + /// held back runs once its save is written and closed, if the document is still the same and + /// unchanged since; a cancelled or failed save brings the prompt back with the reason. + pub(crate) fn settle_async_saves(&mut self) { + let Some(saves) = self.services.async_saves.clone() else { return }; + for (ticket, outcome) in saves.take_finished() { + let current = self.session.document_id(); + if let Some(i) = self.saves_in_flight.iter().position(|s| s.ticket == ticket) { + let save = self.saves_in_flight.swap_remove(i); + match &outcome { + async_saves::SaveOutcome::Saved => log::info!("saved {} through the save picker", save.name), + async_saves::SaveOutcome::Cancelled | async_saves::SaveOutcome::Failed(_) => { + if save.marked_saved && save.document == current { + self.session.dirty = true; + } + } + } + if let async_saves::SaveOutcome::Failed(error) = &outcome { + let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &save.name), ("error", error)]); + log::error!("the save picker couldn't save {}: {error}", save.name); + self.status(msg); + } + } + let Some(wait) = self.awaiting_save.take_if(|w| w.ticket == ticket) else { continue }; + if wait.document != current { + log::info!("the document a held-back {} was for has been replaced; dropped", wait.then); + continue; + } + match outcome { + // Only when nothing changed since and no later save of it is still unconfirmed. + async_saves::SaveOutcome::Saved if !self.session.dirty && self.session.rev() == wait.rev && !self.save_unconfirmed() => { + if let Err(e) = self.execute_user(&wait.then, wait.params) { + self.status(e); + } + } + // Edited while the picker was open (those edits may not be written yet): ask again. + async_saves::SaveOutcome::Saved => { + let name = self.prompt_name(); + self.dialog = Some(dialogs::Dialog::SaveChanges { + name, + then: wait.then, + params: wait.params, + document: wait.document, + message: String::new(), + }); + } + async_saves::SaveOutcome::Cancelled => { + let name = self.prompt_name(); + let message = i18n::fmt(tl!("Saving {name} was cancelled, so it is still open and nothing was lost."), &[("name", &name)]); + self.prompt_again(message, wait.then, wait.params, wait.document); + } + async_saves::SaveOutcome::Failed(error) => { + self.save_failed(&error, wait.then, wait.params, wait.document); + } + } + } + } + /// Whether a save before New/Open/Close counts: it happened, and kept everything. fn safely_saved(&mut self, saved: bool) -> bool { if saved && self.session.dirty { @@ -1425,6 +1570,7 @@ impl WordApp { self.applied_editing = Some(lang); } options::sync(self); + self.settle_async_saves(); // Chinese text wants the Chinese face before the Japanese one (one glyph style per line). // An installed CJK font is read only once CJK text is shown (#241): a CJK interface, the // language names in Options, or a file name the caption is drawing. A file opened later @@ -3982,6 +4128,168 @@ mod tests { assert!(msg.contains("blocked by the browser"), "the status bar says why: {msg:?}"); } + /// #1315: a web app whose `download` hands the save to the browser's picker, which finishes + /// later. Returns the app (with unsaved typing) and the saves its download starts, plus the + /// last ticket started. + fn web_picker_app() -> (WordApp, async_saves::AsyncSaves, std::rc::Rc>) { + let mut a = typed(); + let saves = async_saves::AsyncSaves::default(); + let last = std::rc::Rc::new(std::cell::Cell::new(0)); + let (hook, seen) = (saves.clone(), last.clone()); + a.services.download = Some(Box::new(move |_, _| { + seen.set(hook.start()); + Ok(()) + })); + a.services.async_saves = Some(saves.clone()); + (a, saves, last) + } + + /// #1315: Save in the prompt before New waits for the picker: New runs only once the file + /// is written and closed. + #[test] + fn web_save_before_new_waits_for_the_picker_to_finish() { + let (mut a, saves, last) = web_picker_app(); + a.run("file.new", json!({})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges")); + let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + assert_eq!(r["pending"], "save", "{r}"); + assert_eq!(saves.pending(), 1); + a.settle_async_saves(); + assert!(body_text(&a).contains(UNSAVED), "nothing is replaced while the picker is open"); + saves.finish(last.get(), async_saves::SaveOutcome::Saved); + a.settle_async_saves(); + assert!(!body_text(&a).contains(UNSAVED), "the New ran once the save finished"); + assert!(!a.session.dirty); + assert_eq!(prompt(&a), None); + } + + /// #1315: cancelling the picker, or a write/close that fails, keeps the document: its text, + /// its undo, still unsaved, and the prompt back with the reason. Nothing is replaced. + #[test] + fn web_save_before_new_that_is_cancelled_or_fails_keeps_the_document() { + for (outcome, reason) in + [(async_saves::SaveOutcome::Cancelled, "cancel"), (async_saves::SaveOutcome::Failed("the disk is full".into()), "the disk is full")] + { + let (mut a, saves, last) = web_picker_app(); + a.run("file.new", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + a.status_msg = None; + saves.finish(last.get(), outcome.clone()); + a.settle_async_saves(); + assert!(body_text(&a).contains(UNSAVED), "{outcome:?}: nothing was replaced"); + assert!(a.session.dirty, "{outcome:?}: still unsaved"); + assert_eq!(prompt(&a), Some("saveChanges"), "{outcome:?}: the prompt is back"); + let message = prompt_message(&a).unwrap_or_default(); + assert!(message.contains("still open") && message.contains(reason), "{outcome:?}: {message}"); + let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(status.contains(reason), "{outcome:?}: the status bar says why: {status:?}"); + // The undo history is still there. + a.execute("edit.undo", json!({})).unwrap(); + assert!(!body_text(&a).contains(UNSAVED), "{outcome:?}: the typing can be undone"); + } + } + + /// #1315 review: the prompt's Save is in the picker (ticket 1), the user edits and saves + /// again (ticket 2, which marks the document saved at once). Ticket 1 ending doesn't let the + /// held-back New replace the document while ticket 2 hasn't written its edits. + #[test] + fn web_overlapping_saves_hold_the_replacement() { + let (mut a, saves, last) = web_picker_app(); + a.run("file.new", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + let first = last.get(); + a.run("text.insert", json!({"text": "Later edit "})).unwrap(); + a.execute("file.save", json!({})).unwrap(); + assert!(!a.session.dirty, "the second save marked the document saved"); + saves.finish(first, async_saves::SaveOutcome::Saved); + a.settle_async_saves(); + assert!(body_text(&a).contains("Later edit") && body_text(&a).contains(UNSAVED), "nothing was replaced"); + assert_eq!(prompt(&a), Some("saveChanges"), "the prompt asks again"); + } + + /// #1315 review: edited while the picker was open, then the save ends: those edits aren't + /// in the file, so the prompt asks again instead of running New. + #[test] + fn web_save_that_ends_after_an_edit_asks_again() { + let (mut a, saves, last) = web_picker_app(); + a.run("file.new", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + a.run("text.insert", json!({"text": "Later edit "})).unwrap(); + saves.finish(last.get(), async_saves::SaveOutcome::Saved); + a.settle_async_saves(); + assert!(body_text(&a).contains("Later edit"), "nothing was replaced"); + assert!(a.session.dirty); + assert_eq!(prompt(&a), Some("saveChanges")); + assert_eq!(prompt_message(&a).as_deref(), Some("")); + } + + /// #1315 review: the status bar says New/Open is waiting for the save; a cancelled picker + /// says it was cancelled, not that saving failed. + #[test] + fn web_save_prompt_says_it_waits_and_that_a_cancel_was_a_cancel() { + let (mut a, saves, last) = web_picker_app(); + a.run("file.new", json!({})).unwrap(); + a.status_msg = None; + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(status.contains("Waiting for the save"), "{status:?}"); + saves.finish(last.get(), async_saves::SaveOutcome::Cancelled); + a.settle_async_saves(); + let message = prompt_message(&a).unwrap_or_default(); + assert!(message.contains("cancelled") && message.contains("still open"), "{message}"); + assert!(!message.contains("Couldn't save"), "a cancel isn't a failure: {message}"); + } + + /// #1315 review: a document dropped or picked on the web (`file.open` with its bytes) waits + /// for the save too, then opens. + #[test] + fn web_save_before_open_opens_the_file_once_saved() { + let dir = scratch("web-open-after-save"); + let bytes = std::fs::read(docx_on_disk(&dir, "Picked document")).unwrap(); + let (mut a, saves, last) = web_picker_app(); + let data = wordcraft_engine::cmd::insert::base64_encode(&bytes); + a.run("file.open", json!({"path": "picked.docx", "data": data})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + a.settle_async_saves(); + assert!(body_text(&a).contains(UNSAVED), "nothing opened while the picker is open"); + saves.finish(last.get(), async_saves::SaveOutcome::Saved); + a.settle_async_saves(); + assert!(body_text(&a).contains("Picked document"), "the picked file opened: {:?}", body_text(&a)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1315: while the picker save is in flight the document's save isn't confirmed, so New + /// asks again instead of replacing it. + #[test] + fn web_new_while_a_picker_save_is_in_flight_asks_first() { + let (mut a, _saves, _last) = web_picker_app(); + a.execute("file.save", json!({})).unwrap(); + a.run("file.new", json!({})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges")); + assert!(body_text(&a).contains(UNSAVED), "nothing was replaced"); + } + + /// #1315: a save that ends after the document was replaced neither runs the held-back New on + /// the new document nor marks it unsaved. + #[test] + fn web_picker_save_ending_after_the_document_changed_leaves_the_new_one_alone() { + for outcome in [async_saves::SaveOutcome::Saved, async_saves::SaveOutcome::Failed("gone".into())] { + let (mut a, saves, last) = web_picker_app(); + a.run("file.new", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + // Another document replaces it (an agent's or a script's command) while the picker is open. + a.execute("file.new", json!({})).unwrap(); + a.execute("text.insert", json!({"text": "Second document"})).unwrap(); + a.session.dirty = false; + saves.finish(last.get(), outcome.clone()); + a.settle_async_saves(); + assert!(body_text(&a).contains("Second document"), "{outcome:?}: the held-back New didn't run on it"); + assert!(!a.session.dirty, "{outcome:?}: the new document isn't marked unsaved"); + assert_eq!(prompt(&a), None, "{outcome:?}"); + } + } + /// A new document has no path: Save goes through Save As, and cancelling that cancels the New. #[test] fn save_as_cancelled_keeps_the_document() { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b3ca7d96e..b8d4d2abc 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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 @@ -53,6 +53,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | +| 2026-10-14 | trivial | Save Changes prompt on the web: Save before New/Open holds the command back until the browser's save picker has written and closed the file; a cancelled or failed picker save keeps the document and brings the prompt back with the reason (#1315) | | 2026-10-14 | trivial | Page Setup: a Paper group with Paper size (the named sizes, the one the section has in either orientation, or Custom size) and Width and Height in the measurement unit; a wider-than-tall size turns the orientation to landscape; a size the margins don't fit keeps the dialog open with a message and applies nothing. Layout › Size ends with More Paper Sizes…, which opens it (#1433) | | 2026-10-13 | trivial | Dark page (View › Switch Modes): highlights and character shading are dimmed toward black, keeping their hue (yellow turns a dim olive), instead of having their lightness inverted, which left yellow bright under white text; text on them draws light (white for black and Automatic), coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule. Light page, print and exports unchanged. Unlike Word, Automatic text stays white on the dark page (#1252) | | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | From b10c97b7656c24c37109054dfa0b289b5cb66ecf Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:28:00 +0200 Subject: [PATCH 082/199] Document Inspector: find hidden text outside the body and from styles (#1344) (#1515) * #1344: WIP * #1344: Inspect Document finds and removes hidden text in every story and from styles Co-Authored-By: Claude Opus 5.5 * #1344: hidden paragraphs, note references and table-style text handled; faster removal Co-Authored-By: Claude Opus 5.5 * #1344: a hidden paragraph between tables stays; note echo capped Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/tools.rs | 282 ++++++++++++++++++++++++++++++--- crates/engine/src/tests.rs | 267 +++++++++++++++++++++++++++++++ docs/ui-parity.md | 5 +- 4 files changed, 534 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 29e2f917e..ede6bde90 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: the web's Save before New/Open waits for the save picker, #1315; 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: 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: Inspect Document hidden text, #1344; 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: 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. @@ -155,6 +155,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) | | 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) | | 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | +| 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake | | 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | | 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | @@ -332,6 +333,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) | | 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) | | 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | +| 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) | | 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | | 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 669601433..8b5c9f9b5 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -387,7 +387,7 @@ pub fn specs() -> Vec { CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(COMPARE_PARAMS), CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(), CommandSpec::new("file.inspect", "Inspect Document", "File › Info", inspect_doc) - .params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]}"#), + .params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]} → {found: {comments, revisions, properties, hiddenText, hiddenCount, hidden: [{story: "body|header|footer|footnote|endnote|comment|textBox", part?, text}] (at most 1000), truncated, unsupported: [{story, object: "table|opaque", ..}] (hidden text not inspected: table styles, verbatim objects), headers}, removed, residual: [..] (hidden text a removal couldn't reach), removedNotes: [{kind, part, text}] (notes whose hidden reference was removed)}; whole hidden paragraphs go with their marks"#), CommandSpec::new("file.protect", "Protect Document", "File › Info", |s, v| { let mode = p::str(v, "mode").unwrap_or("readOnly"); s.run("review.restrict", &json!({"mode": mode})) @@ -1263,18 +1263,19 @@ fn accessibility(s: &mut Session, _: &Value) -> CmdResult { fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult { let remove: Vec = v.get("remove").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); - let has_hidden = s - .doc - .para_paths(StoryRef::Body) - .iter() - .any(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.runs.iter().any(|r| r.props.hidden == Some(true)))); + let hidden = hidden_text(&s.doc); let report = json!({ "comments": s.doc.comments.len(), "revisions": s.doc.para_paths(StoryRef::Body).iter().filter(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.runs.iter().any(|r| r.props.ins.is_some() || r.props.del.is_some()))).count(), "properties": !s.doc.core.creator.is_empty() || !s.doc.core.last_modified_by.is_empty(), - "hiddenText": has_hidden, + "hiddenText": !hidden.is_empty(), + "hiddenCount": hidden.len(), + "hidden": hidden.iter().take(MAX_HIDDEN_LISTED).map(|h| h.report(&s.doc)).collect::>(), + "truncated": hidden.len() > MAX_HIDDEN_LISTED, + "unsupported": unsupported_hidden(&s.doc), "headers": s.doc.parts.values().filter(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer)).count(), }); + let mut removed_notes = Vec::new(); for r in &remove { match r.as_str() { "comments" => { @@ -1288,19 +1289,7 @@ fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult { s.doc.core = Default::default(); s.doc.core.title = title; } - "hidden" => { - for path in s.doc.para_paths(StoryRef::Body) { - let ranges: Vec<(usize, usize)> = s - .doc - .para(StoryRef::Body, &path) - .map(|p| p.run_ranges().filter(|(_, c)| c.hidden == Some(true)).map(|(r, _)| (r.start, r.end)).collect()) - .unwrap_or_default(); - let para = s.doc.para_mut(StoryRef::Body, &path)?; - for (a, b) in ranges.into_iter().rev() { - para.delete(a, b)?; - } - } - } + "hidden" => removed_notes = remove_hidden(&mut s.doc)?, "headers" => { s.run("insert.removeHeader", &json!({}))?; s.run("insert.removeFooter", &json!({}))?; @@ -1309,7 +1298,258 @@ fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult { } } s.clamp_selection(); - Ok(json!({"found": report, "removed": remove})) + // What a removal couldn't reach: hidden text still there, or kept in objects we don't model. + let residual: Vec = if remove.iter().any(|r| r == "hidden") { + let mut v: Vec = hidden_text(&s.doc).iter().take(MAX_HIDDEN_LISTED).map(|h| h.report(&s.doc)).collect(); + v.extend(unsupported_hidden(&s.doc)); + v + } else { + Vec::new() + }; + Ok(json!({"found": report, "removed": remove, "residual": residual, "removedNotes": removed_notes})) +} + +/// Most hidden pieces (and removed notes) `file.inspect` lists (the count is always exact). +const MAX_HIDDEN_LISTED: usize = 1000; +/// Most characters of a removed note's text `file.inspect` echoes. +const MAX_NOTE_TEXT: usize = 1000; + +/// A piece of hidden text: a byte range of a paragraph in a story. `whole`: the paragraph's mark +/// is hidden too and so is all its text, so the paragraph itself goes. +struct Hidden { + story: StoryRef, + path: wordcraft_doc::Path, + range: std::ops::Range, + whole: bool, +} + +/// Remove All for hidden text (#1344): every hidden piece, last first so earlier offsets and +/// paths stay valid; whole hidden paragraphs with their marks; and the notes whose references +/// were hidden, as deleting a reference deletes its note. Returns the notes removed. +fn remove_hidden(doc: &mut Document) -> Result, CmdError> { + let mut refs: Vec = Vec::new(); + for h in hidden_text(doc).into_iter().rev() { + if let Some(p) = doc.para(h.story, &h.path) { + for off in p.object_offsets() { + if h.range.contains(&off) + && let Some(InlineObject::NoteRef { id, .. }) = p.object_at(off) + { + refs.push(*id); + } + } + } + remove_piece(doc, &h)?; + } + if refs.is_empty() { + return Ok(Vec::new()); + } + // Notes still referenced from elsewhere (a note's own mark doesn't count) stay. + let mut kept = std::collections::BTreeSet::new(); + for story in all_stories(doc) { + for path in doc.para_paths(story) { + let Some(p) = doc.para(story, &path) else { continue }; + for off in p.object_offsets() { + if let Some(InlineObject::NoteRef { id, .. }) = p.object_at(off) + && story != StoryRef::Part(*id) + { + kept.insert(*id); + } + } + } + } + refs.sort_unstable(); + refs.dedup(); + let mut out = Vec::new(); + for id in refs { + if kept.contains(&id) + || !doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote)) + { + continue; + } + // Only the echo is capped; every such note goes. + if out.len() < MAX_HIDDEN_LISTED { + let text: String = doc.plain_text(StoryRef::Part(id)).replace('\u{FFFC}', "").trim().chars().take(MAX_NOTE_TEXT).collect(); + out.push(json!({"kind": story_kind(doc, StoryRef::Part(id)), "part": id, "text": text})); + } + doc.parts.remove(&id); + } + Ok(out) +} + +/// Whether a whole hidden paragraph can go, mark and all. It stays when it is the last block of +/// its story or cell (they end with a paragraph), carries a section break, holds content control +/// markers (their partners may be elsewhere), or sits between two tables (they'd join on reopen). +fn removable(doc: &Document, story: StoryRef, path: &wordcraft_doc::Path, p: &Paragraph) -> bool { + let i = path.last(); + let Some(c) = doc.container(story, path) else { return false }; + let is_table = |j: Option| j.and_then(|j| c.get(j)).is_some_and(|b| matches!(**b, Block::Table(_))); + i + 1 < c.len() + && p.section.is_none() + && !p.object_offsets().into_iter().any(|o| matches!(p.object_at(o), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd))) + && !(is_table(i.checked_sub(1)) && is_table(i.checked_add(1))) +} + +/// Whether `range` of `p` holds text or an object, not only zero-width markers. +fn has_content(p: &Paragraph, range: &std::ops::Range) -> bool { + p.text + .get(range.clone()) + .is_some_and(|t| t.char_indices().any(|(i, ch)| ch != '\u{FFFC}' || p.object_at(range.start + i).is_some_and(|o| !o.is_marker()))) +} + +/// Delete one hidden piece. Within a paragraph: straight in the paragraph, unless a content +/// control marker is inside (then through `delete_range`, which keeps markers whose partner is +/// outside). A whole hidden paragraph goes with its mark when [`removable`]; otherwise its text +/// goes and it stays, empty, its mark still hidden: invisible and holding no text, so it isn't +/// reported again. +fn remove_piece(doc: &mut Document, h: &Hidden) -> Result<(), CmdError> { + let Some(p) = doc.para(h.story, &h.path) else { return Ok(()) }; + let has_control = p + .object_offsets() + .into_iter() + .any(|o| h.range.contains(&o) && matches!(p.object_at(o), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd))); + if h.whole && removable(doc, h.story, &h.path, p) { + let i = h.path.last(); + let blocks = doc.container_mut(h.story, &h.path)?; + if i < blocks.len() { + blocks.remove(i); + } + return Ok(()); + } + if !h.range.is_empty() { + if has_control { + let at = |off| Pos { story: h.story, path: h.path.clone(), off }; + doc.delete_range(&at(h.range.start), &at(h.range.end))?; + } else { + doc.para_mut(h.story, &h.path)?.delete(h.range.start, h.range.end)?; + } + } + Ok(()) +} + +impl Hidden { + fn report(&self, doc: &Document) -> Value { + let text = doc.para(self.story, &self.path).and_then(|p| p.text.get(self.range.clone())).unwrap_or(""); + let mut v = json!({"story": story_kind(doc, self.story), "text": text}); + if let (StoryRef::Part(id), Value::Object(m)) = (self.story, &mut v) { + m.insert("part".into(), json!(id)); + } + v + } +} + +/// Every story of the document: the body, then headers, footers, notes, comments and text boxes. +fn all_stories(doc: &Document) -> Vec { + std::iter::once(StoryRef::Body).chain(doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() +} + +/// What the inspector calls a story. +fn story_kind(doc: &Document, st: StoryRef) -> &'static str { + use wordcraft_doc::PartKind; + match st { + StoryRef::Body => "body", + StoryRef::Part(id) => match doc.parts.get(&id).map(|p| p.kind) { + Some(PartKind::Header) => "header", + Some(PartKind::Footer) => "footer", + Some(PartKind::Footnote) => "footnote", + Some(PartKind::Endnote) => "endnote", + Some(PartKind::Comment) => "comment", + Some(PartKind::TextBox) => "textBox", + None => "unknown", + }, + } +} + +/// The hidden text of the document (Inspect Document, #1344): runs whose effective formatting +/// (document defaults, paragraph style chain, character style chain, direct) is hidden, in every +/// story, adjacent runs merged. Ranges holding only zero-width markers (bookmarks, comment +/// ranges, content control and field markers) aren't text and are left out. +fn hidden_text(doc: &Document) -> Vec { + let mut out = Vec::new(); + for story in all_stories(doc) { + for path in doc.para_paths(story) { + let Some(p) = doc.para(story, &path) else { continue }; + let style = p.props.style.as_deref(); + let mut ranges: Vec> = Vec::new(); + let mut all_hidden = true; + for (r, c) in p.run_ranges() { + if r.is_empty() { + continue; + } + if !doc.styles.resolve_char(style, c).hidden { + all_hidden = false; + continue; + } + match ranges.last_mut() { + Some(last) if last.end == r.start => last.end = r.end, + _ => ranges.push(r), + } + } + if all_hidden && doc.styles.resolve_char(style, &p.mark).hidden { + // An empty hidden paragraph that has to stay is invisible and holds no text. + if removable(doc, story, &path, p) || has_content(p, &(0..p.len())) { + out.push(Hidden { story, path: path.clone(), range: 0..p.len(), whole: true }); + } + continue; + } + for range in ranges { + if has_content(p, &range) { + out.push(Hidden { story, path: path.clone(), range, whole: false }); + } + } + } + } + out +} + +/// Hidden text the inspector can't reach: objects kept verbatim from the source file +/// (`Opaque`) whose markup hides text (`w:vanish`), outside hidden runs (those go with the run). +fn unsupported_hidden(doc: &Document) -> Vec { + let mut out = Vec::new(); + for story in all_stories(doc) { + for path in doc.para_paths(story) { + let Some(p) = doc.para(story, &path) else { continue }; + let style = p.props.style.as_deref(); + for off in p.object_offsets() { + if let Some(InlineObject::Opaque { format, xml, .. }) = p.object_at(off) + && xml.contains("vanish") + && !doc.styles.resolve_char(style, p.props_of_char(off)).hidden + { + out.push(json!({"story": story_kind(doc, story), "object": "opaque", "format": format, "reason": "hidden text inside an object kept from the source file"})); + } + } + } + if let Some(blocks) = doc.story(story) { + tables_hiding_text(doc, story, blocks, 0, &mut out); + } + } + out +} + +/// Tables whose table style (or one of its conditional parts: header row, bands, first or last +/// column, total row) hides text. The inspector doesn't resolve table styles, so it lists them. +fn tables_hiding_text(doc: &Document, story: StoryRef, blocks: &wordcraft_doc::Blocks, depth: usize, out: &mut Vec) { + if depth > 32 { + return; + } + for b in blocks { + let Block::Table(t) = &**b else { continue }; + if let Some(id) = t.props.style.as_deref() + && let Some(ts) = doc.styles.table_style(id) + { + let q = &ts.parts; + if [&ts.chr, &q.header_chr, &q.band_chr, &q.col_band_chr, &q.first_col_chr, &q.last_col_chr, &q.total_chr] + .iter() + .any(|c| c.hidden == Some(true)) + { + out.push(json!({"story": story_kind(doc, story), "object": "table", "style": id, "reason": "text hidden by a table style isn't inspected or removed"})); + } + } + for row in &t.rows { + for cell in &row.cells { + tables_hiding_text(doc, story, &cell.blocks, depth + 1, out); + } + } + } } fn quick_parts(s: &mut Session, v: &Value) -> CmdResult { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eec8ffffa..de72ecefe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5455,6 +5455,273 @@ fn insert_picture_takes_a_windows_metafile() { let _ = std::fs::remove_dir_all(&dir); } +/// #1344: File › Info › Inspect Document finds hidden text by its effective formatting (direct, +/// character style, paragraph style) in every story — body, header, footer, footnote, comment, +/// text box — and Remove All deletes exactly that text, in one undo step, through a DOCX save. +#[test] +fn inspect_document_hidden_text_in_every_story_and_from_styles() { + use wordcraft_doc::props::{CharProps, ParaProps}; + use wordcraft_doc::styles::{Style, StyleKind}; + use wordcraft_doc::{Paragraph, PartKind, para_block}; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Body words"})); + run(&mut s, "review.newComment", json!({"text": "c"})); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "insert.textBox", json!({"text": "t"})); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "references.footnote", json!({"text": "n"})); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "references.endnote", json!({"text": "e"})); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "insert.header", json!({"text": "h"})); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "insert.footer", json!({"text": "f"})); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + let hidden = CharProps { hidden: Some(true), ..Default::default() }; + s.doc.styles.upsert(Style { id: "HideC".into(), name: "Hide C".into(), kind: StyleKind::Character, chr: hidden.clone(), ..Default::default() }); + s.doc.styles.upsert(Style { + id: "HideP".into(), + name: "Hide P".into(), + kind: StyleKind::Paragraph, + based_on: Some("Normal".into()), + chr: hidden.clone(), + ..Default::default() + }); + let plain = CharProps::default(); + let blocks = |tag: &str| { + let mut a = Paragraph::with_text(&format!("keep-{tag}-1 "), plain.clone()); + for (t, c) in [ + (format!("hide-{tag}-direct"), hidden.clone()), + (format!(" keep-{tag}-2 "), plain.clone()), + (format!("hide-{tag}-cstyle"), CharProps { style: Some("HideC".into()), ..Default::default() }), + (format!(" keep-{tag}-3"), plain.clone()), + ] { + let at = a.len(); + a.insert_text(at, &t, &c).unwrap(); + } + let mut b = Paragraph::with_text(&format!("hide-{tag}-pstyle"), plain.clone()); + b.props = ParaProps { style: Some("HideP".into()), ..Default::default() }; + // Shown again by direct formatting over the hidden paragraph style: it stays. + let mut c = Paragraph::with_text(&format!("keep-{tag}-4"), CharProps { hidden: Some(false), ..Default::default() }); + c.props = ParaProps { style: Some("HideP".into()), ..Default::default() }; + vec![para_block(a), para_block(b), para_block(c)] + }; + let tag = |k: PartKind| match k { + PartKind::Header => "header", + PartKind::Footer => "footer", + PartKind::Footnote => "footnote", + PartKind::Endnote => "endnote", + PartKind::Comment => "comment", + PartKind::TextBox => "textBox", + }; + s.doc.body.extend(blocks("body")); + // A table cell in the body, with the same pieces. + let mut table = wordcraft_doc::table::Table::new(1, 2, 200.0); + table.rows[0].cells[0].blocks = blocks("cell"); + s.doc.body.push(std::sync::Arc::new(wordcraft_doc::Block::Table(table))); + s.doc.body.push(para_block(Paragraph::new())); + let mut tags = vec![("body", "body"), ("cell", "body")]; + for part in s.doc.parts.values_mut() { + part.blocks.extend(blocks(tag(part.kind))); + tags.push((tag(part.kind), tag(part.kind))); + } + tags.sort(); + assert_eq!(tags.iter().map(|t| t.0).collect::>(), ["body", "cell", "comment", "endnote", "footer", "footnote", "header", "textBox"]); + let stories = |d: &wordcraft_doc::Document| std::iter::once(StoryRef::Body).chain(d.parts.keys().map(|k| StoryRef::Part(*k))).collect::>(); + let para_counts = |d: &wordcraft_doc::Document| stories(d).into_iter().map(|st| (st, d.para_paths(st).len())).collect::>(); + let counts_before = para_counts(&s.doc); + let all_text = |d: &wordcraft_doc::Document| -> String { + std::iter::once(StoryRef::Body).chain(d.parts.keys().map(|k| StoryRef::Part(*k))).map(|st| d.plain_text(st)).collect::>().join("\n") + }; + + // Inspect reports every hidden piece, in its story. + let found = run(&mut s, "file.inspect", json!({}))["found"].clone(); + assert_eq!(found["hiddenText"], true); + let items = found["hidden"].as_array().cloned().unwrap_or_default(); + for (t, story) in &tags { + for how in ["direct", "cstyle", "pstyle"] { + let want = format!("hide-{t}-{how}"); + assert!( + items.iter().any(|i| i["story"] == *story && i["text"].as_str().is_some_and(|x| x.contains(&want))), + "{want} not reported: {items:?}" + ); + } + } + assert!(!items.iter().any(|i| i["text"].as_str().is_some_and(|x| x.contains("keep-"))), "visible text reported: {items:?}"); + + // Remove All: every hidden piece goes, every visible one stays, in one undo step. + let before = all_text(&s.doc); + let undo_len = s.undo_labels().len(); + let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); + assert_eq!(out["residual"], json!([]), "{out}"); + let after = all_text(&s.doc); + for (t, _) in &tags { + for n in 1..=4 { + assert!(after.contains(&format!("keep-{t}-{n}")), "keep-{t}-{n} lost:\n{after}"); + } + } + assert!(!after.contains("hide-"), "hidden text left:\n{after}"); + // The paragraphs hidden through their style went, marks and all (the body lost two: its own + // and the table cell's); the paragraph shown again by direct formatting stayed. + let expected: Vec<_> = counts_before.iter().map(|(st, n)| (*st, n - if *st == StoryRef::Body { 2 } else { 1 })).collect(); + assert_eq!(para_counts(&s.doc), expected); + assert_eq!(s.undo_labels().len(), undo_len + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(all_text(&s.doc), before, "one undo brings it all back"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(all_text(&s.doc), after); + assert_eq!(run(&mut s, "file.inspect", json!({}))["found"]["hiddenText"], false); + + // Save and reopen: none of the hidden strings is in the file. + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let reopened = all_text(&back); + assert!(!reopened.contains("hide-"), "hidden text in the saved file:\n{reopened}"); + assert!(reopened.contains("keep-header-4") && reopened.contains("keep-comment-3"), "{reopened}"); + assert!(reopened.contains("keep-endnote-2") && reopened.contains("keep-cell-4"), "{reopened}"); +} + +fn hidden_props() -> wordcraft_doc::props::CharProps { + wordcraft_doc::props::CharProps { hidden: Some(true), ..Default::default() } +} + +/// #1344: document defaults that hide text hide it everywhere they aren't overridden. +#[test] +fn inspect_document_removes_text_hidden_by_document_defaults() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{Paragraph, para_block}; + let mut s = s(); + s.doc.styles.default_chr.hidden = Some(true); + s.doc.body = vec![ + para_block(Paragraph::with_text("hide-default", CharProps::default())), + para_block(Paragraph::with_text("keep-default", CharProps { hidden: Some(false), ..Default::default() })), + ]; + let found = run(&mut s, "file.inspect", json!({}))["found"].clone(); + assert!(found["hidden"].as_array().unwrap().iter().any(|i| i["text"] == "hide-default"), "{found}"); + let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); + assert_eq!(out["residual"], json!([]), "{out}"); + assert_eq!(text(&s), "keep-default"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1); +} + +/// #1344: a hidden piece holding a content control's end keeps the marker (its start is outside). +#[test] +fn inspect_document_keeps_content_control_markers() { + use wordcraft_doc::para::InlineObject; + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{Paragraph, para_block}; + let mut s = s(); + let mut p = Paragraph::with_text("keep-a ", CharProps::default()); + let plain = CharProps::default(); + p.insert_object(p.len(), InlineObject::ControlStart { control: Box::default() }, &plain).unwrap(); + p.insert_text(p.len(), "keep-cc ", &plain).unwrap(); + p.insert_text(p.len(), "hide-cc", &hidden_props()).unwrap(); + p.insert_object(p.len(), InlineObject::ControlEnd, &hidden_props()).unwrap(); + p.insert_text(p.len(), "hide-after", &hidden_props()).unwrap(); + p.insert_text(p.len(), " keep-b", &plain).unwrap(); + s.doc.body = vec![para_block(p)]; + let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); + assert_eq!(out["residual"], json!([]), "{out}"); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + assert_eq!(p.text.replace('\u{FFFC}', ""), "keep-a keep-cc keep-b"); + let objs: Vec<_> = p.object_offsets().into_iter().filter_map(|o| p.object_at(o)).collect(); + assert!(matches!(objs.as_slice(), [InlineObject::ControlStart { .. }, InlineObject::ControlEnd]), "{objs:?}"); +} + +/// #1344: text hidden by a table style isn't resolved by the inspector, so it says so: the table +/// is listed as unsupported and the removal's residual isn't empty. +#[test] +fn inspect_document_lists_tables_whose_style_hides_text() { + use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts}; + let mut s = s(); + s.doc.styles.upsert(Style { + id: "HideHeader".into(), + name: "Hide Header".into(), + kind: StyleKind::Table, + table: Some(TableStyleParts { header_chr: hidden_props(), ..Default::default() }), + ..Default::default() + }); + let mut table = wordcraft_doc::table::Table::new(2, 2, 200.0); + table.props.style = Some("HideHeader".into()); + s.doc.body.insert(0, std::sync::Arc::new(wordcraft_doc::Block::Table(table))); + let found = run(&mut s, "file.inspect", json!({}))["found"].clone(); + let unsupported = found["unsupported"].as_array().cloned().unwrap_or_default(); + assert!(unsupported.iter().any(|u| u["object"] == "table" && u["story"] == "body" && u["style"] == "HideHeader"), "{found}"); + let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); + assert!(out["residual"].as_array().is_some_and(|r| r.iter().any(|u| u["object"] == "table")), "{out}"); +} + +/// #1344: removing a hidden footnote reference removes its note too, as deleting a reference does, +/// and reports the note's text. +#[test] +fn inspect_document_removes_the_note_of_a_hidden_reference() { + use wordcraft_doc::PartKind; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Body"})); + let id = run(&mut s, "references.footnote", json!({"text": "note-visible"}))["id"].as_u64().unwrap() as u32; + let path = wordcraft_doc::Path::top(0); + let p = s.doc.para_mut(StoryRef::Body, &path).unwrap(); + let off = p.object_offsets()[0]; + p.format(off, off + '\u{FFFC}'.len_utf8(), &|c| c.hidden = Some(true)).unwrap(); + let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); + assert_eq!(out["residual"], json!([]), "{out}"); + assert!(!s.doc.parts.contains_key(&id), "orphan note part left"); + let notes = out["removedNotes"].as_array().cloned().unwrap_or_default(); + assert!(notes.iter().any(|n| n["kind"] == "footnote" && n["text"].as_str().is_some_and(|t| t.contains("note-visible"))), "{out}"); + assert_eq!(text(&s), "Body"); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert!(!back.parts.values().any(|p| p.kind == PartKind::Footnote), "note in the saved file"); + assert_eq!(back.plain_text(StoryRef::Body), "Body"); +} + +/// #1344: many hidden pieces (index entries are hidden text) are removed quickly, and the +/// found list is capped. +#[test] +fn inspect_document_removes_thousands_of_hidden_pieces() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{Paragraph, para_block}; + let mut s = s(); + s.doc.body = (0..2000) + .map(|i| { + let mut p = Paragraph::with_text(&format!("word{i} "), CharProps::default()); + p.insert_text(p.len(), &format!("XE \"entry{i}\""), &hidden_props()).unwrap(); + p.insert_text(p.len(), " more", &CharProps::default()).unwrap(); + para_block(p) + }) + .collect(); + let t = std::time::Instant::now(); + let found = run(&mut s, "file.inspect", json!({}))["found"].clone(); + assert_eq!(found["hiddenCount"], 2000); + assert_eq!(found["hidden"].as_array().map(Vec::len), Some(1000)); + assert_eq!(found["truncated"], true); + let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); + assert_eq!(out["residual"], json!([])); + assert!(!text(&s).contains("XE")); + assert!(text(&s).contains("word1999 more")); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); +} + +/// #1344: a hidden paragraph between two tables stays (emptied, its mark still hidden), or the +/// tables would join into one when the file is reopened. +#[test] +fn inspect_document_keeps_a_hidden_paragraph_between_tables() { + use wordcraft_doc::{Block, Paragraph, para_block}; + let mut s = s(); + let table = || std::sync::Arc::new(Block::Table(wordcraft_doc::table::Table::new(1, 1, 100.0))); + let mut p = Paragraph::with_text("hide-between", hidden_props()); + p.mark.hidden = Some(true); + s.doc.body = vec![table(), para_block(p), table(), para_block(Paragraph::with_text("keep-end", Default::default()))]; + let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]})); + assert_eq!(out["residual"], json!([]), "{out}"); + let kinds = |d: &wordcraft_doc::Document| d.body.iter().map(|b| matches!(**b, Block::Table(_))).collect::>(); + assert_eq!(kinds(&s.doc), [true, false, true, false]); + let kept = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); + assert!(kept.is_empty() && kept.mark.hidden == Some(true), "emptied, mark still hidden: {kept:?}"); + assert!(!s.doc.plain_text(StoryRef::Body).contains("hide-")); + assert_eq!(run(&mut s, "file.inspect", json!({}))["found"]["hiddenText"], false); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(kinds(&back).iter().filter(|t| **t).count(), 2, "the tables stay apart"); +} + /// #1381: the reporter's sample HTML (synthetic, 1.7 KB). const SAMPLE_1381: &str = r##"Testo iniziale

Premessa

diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b8d4d2abc..64a2dd607 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-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: Inspect Document hidden text in every story, #1344; previously trivial: Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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 @@ -21,7 +21,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | -| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766) | | 68% | 4–6 | +| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 85% | 1–2 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — | | Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it; selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows. Missing: a single selected cell's border | ✅ | 95% | 1 | @@ -54,6 +54,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | | 2026-10-14 | trivial | Save Changes prompt on the web: Save before New/Open holds the command back until the browser's save picker has written and closed the file; a cancelled or failed picker save keeps the document and brings the prompt back with the reason (#1315) | +| 2026-10-14 | trivial | Inspect Document (#1344): hidden text found by effective formatting (direct, character style, paragraph style) in the body, headers, footers, notes, comments and text boxes; Remove All deletes it all in one undo step (whole hidden paragraphs with their marks, and the notes of hidden references) and reports a `residual` list, including tables whose style hides text | | 2026-10-14 | trivial | Page Setup: a Paper group with Paper size (the named sizes, the one the section has in either orientation, or Custom size) and Width and Height in the measurement unit; a wider-than-tall size turns the orientation to landscape; a size the margins don't fit keeps the dialog open with a message and applies nothing. Layout › Size ends with More Paper Sizes…, which opens it (#1433) | | 2026-10-13 | trivial | Dark page (View › Switch Modes): highlights and character shading are dimmed toward black, keeping their hue (yellow turns a dim olive), instead of having their lightness inverted, which left yellow bright under white text; text on them draws light (white for black and Automatic), coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule. Light page, print and exports unchanged. Unlike Word, Automatic text stays white on the dark page (#1252) | | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | From 60c4a372d56430b8b0715230c03b76b571b7bb17 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:28:20 +0200 Subject: [PATCH 083/199] RTF: read and write tracked changes (insertions, deletions, authors, times) (#1516) * feat-rtf-revisions: WIP * RTF: read and write tracked changes (gap 19) Co-Authored-By: Claude Opus 5.5 * RTF tracked changes: Unicode authors, \pard keeps revision marks, pasted deletions stay deleted Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/paste.rs | 60 ++++++- crates/engine/src/cmd/review.rs | 19 ++ crates/engine/src/tests.rs | 55 ++++++ crates/formats/src/model.rs | 103 +++++++++-- crates/formats/src/rtf.rs | 309 +++++++++++++++++++++++++++++++- docs/file-format-parity.md | 7 +- docs/gaps.md | 5 +- 8 files changed, 533 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ede6bde90..3e5f4db00 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: Inspect Document hidden text, #1344; 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: 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: RTF tracked changes, gap 19; 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: 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. @@ -156,6 +156,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) | | 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake | +| 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening | | 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | | 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | @@ -334,6 +335,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) | | 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) | +| 2026-10-14 | trivial | Progress log: RTF tracked insertions and deletions (gap 19) | | 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | | 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index b95f6cb00..2450a962f 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -235,6 +235,9 @@ pub fn merge_formatting(s: &Session, frag: &mut Fragment) { run.props.strike = Some(true); } run.props.link = src.link; + // Tracked changes are not formatting: text deleted in the source stays deleted. + run.props.ins = src.ins; + run.props.del = src.del; } p.touch(); }); @@ -257,8 +260,8 @@ pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> { for b in &mut frag.blocks { each_para(b, 0, &mut |p| { for run in &mut p.runs { + // Text deleted in the source stays deleted: inserted, then deleted. run.props.ins = Some(r); - run.props.del = None; run.props.fmt_change = None; } p.mark.ins = None; @@ -304,6 +307,35 @@ pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Para } } +/// Point a tracked formatting change at its renumbered revision (dropped when it has none). +fn remap_change(c: &mut Option>>, rev: &dyn Fn(&mut Option)) { + let mut r = c.as_ref().map(|c| c.rev); + rev(&mut r); + match (r, c.as_mut()) { + (Some(v), Some(x)) => x.rev = v, + _ => *c = None, + } +} + +/// [`remap_change`] for the table, row and cell formatting changes in `b` (bounded depth). +fn remap_table_changes(b: &mut Block, depth: usize, rev: &dyn Fn(&mut Option)) { + if depth > MAX_DEPTH { + return; + } + if let Block::Table(t) = b { + remap_change(&mut t.props.fmt_change, rev); + for row in &mut t.rows { + remap_change(&mut row.props.fmt_change, rev); + for cell in &mut row.cells { + remap_change(&mut cell.props.fmt_change, rev); + for cb in &mut cell.blocks { + remap_table_changes(Arc::make_mut(cb), depth + 1, rev); + } + } + } + } +} + /// Section breaks brought in from another document keep their page, but their header and /// footer ids are that document's: clear them (and the different first page that went with /// them) so the sections link to the previous section's headers and footers, as in Word. @@ -347,6 +379,14 @@ fn adopt(s: &mut Session, src: Document) -> Fragment { s.doc.styles.upsert(st.clone()); } } + // Tracked changes the source carries (RTF revisions) point at its own revisions: add them + // after ours and renumber. + let rev_base = u32::try_from(s.doc.revisions.len()).unwrap_or(u32::MAX); + let src_revs = src.revisions.len(); + s.doc.revisions.extend(src.revisions.iter().cloned()); + let rev = |i: &mut Option| { + *i = i.filter(|v| (*v as usize) < src_revs).and_then(|v| rev_base.checked_add(v)); + }; let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); // Pasted pictures fit our text width, like Insert › Pictures (layout keeps their size). let max_w = crate::io::text_width(&s.doc); @@ -360,6 +400,21 @@ fn adopt(s: &mut Session, src: Document) -> Fragment { { n.num = num_map.get(&n.num).copied().unwrap_or(0); } + for r in &mut p.runs { + rev(&mut r.props.ins); + rev(&mut r.props.del); + remap_change(&mut r.props.fmt_change, &rev); + } + rev(&mut p.mark.ins); + rev(&mut p.mark.del); + remap_change(&mut p.mark.fmt_change, &rev); + remap_change(&mut p.props.fmt_change, &rev); + let mut n = p.props.num_change.as_ref().map(|c| c.rev); + rev(&mut n); + match (n, p.props.num_change.as_mut()) { + (Some(v), Some(c)) => c.rev = v, + _ => p.props.num_change = None, + } for o in &mut p.objects { if let wordcraft_doc::InlineObject::Image { media: key, .. } = o && let Some(k) = media.get(key.as_str()) @@ -369,6 +424,9 @@ fn adopt(s: &mut Session, src: Document) -> Fragment { } }); } + for b in &mut blocks { + remap_table_changes(b, 0, &rev); + } unlink_sections(&mut blocks); // Text box stories the imported shapes show; pasting gives each a new story of its own. let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect(); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 656fd77f6..87d6c49b3 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -1306,4 +1306,23 @@ mod tests { assert_eq!((p.props.align, p.props.fmt_change.is_none()), (Some(wordcraft_doc::props::Align::Center), true)); assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty()); } + + #[test] + fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() { + // Gap 19: an RTF file's tracked insertions and deletions open as tracked changes, also + // after a WordCraft → RTF → WordCraft round trip. + let rtf: &[u8] = br"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}{Bob;}} +\pard Keep {\revised\revauth1\revdttm-1480361314 added }text{\deleted\revauthdel2\revdttmdel-2016341499 gone} end{\revised\revauth1\revdttm-1480361314\deleted\revauthdel2\revdttmdel-2016341499 both}.\par}"; + let opened = wordcraft_formats::rtf::import(rtf).unwrap(); + let again = wordcraft_formats::rtf::import(wordcraft_formats::rtf::export(&opened).as_bytes()).unwrap(); + for doc in [opened, again] { + for (cmd, want) in [("review.acceptAll", "Keep added text end."), ("review.rejectAll", "Keep text gone end.")] { + let mut s = Session::new(doc.clone()); + assert!(!s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty(), "{cmd}"); + s.run(cmd, &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), want, "{cmd}"); + assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty(), "{cmd}"); + } + } + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index de72ecefe..db442f84d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -911,6 +911,61 @@ fn paste_special_lists_formats_and_pastes_text_without_formatting() { assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err()); } +#[test] +fn pasted_rtf_tracked_changes_point_at_their_own_revisions() { + // Gap 19: RTF tracked changes come with revisions of their own; pasted into a document that + // already has revisions, they must not point at the document's (index 0 here is "Zed"). + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "x"})); + s.doc.revisions.push(wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Delete, "Zed", "")); + run(&mut s, "caret.docEnd", json!({})); + let rtf = r"{\rtf1{\*\revtbl {Unknown;}{Alice;}}{\revised\revauth1 added}\par}"; + run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": rtf})); + let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 2 }; + let ins = s.doc.para_at(&pos).and_then(|p| p.props_of_char(2).ins); + let rev = ins.and_then(|i| s.doc.revisions.get(i as usize)); + assert_eq!(rev.map(|r| (r.kind, r.author.as_str())), Some((wordcraft_doc::RevisionKind::Insert, "Alice")), "{:?}", s.doc.revisions); +} + +/// Insertion and deletion revision kinds at body paragraph 0, offset `off`. +fn rev_kinds_at(s: &Session, off: usize) -> (Option, Option) { + let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off }; + let cp = s.doc.para_at(&pos).map(|p| p.props_of_char(off).clone()).unwrap_or_default(); + let kind = |i: Option| i.and_then(|i| s.doc.revisions.get(i as usize)).map(|r| r.kind); + (kind(cp.ins), kind(cp.del)) +} + +const RTF_WITH_DELETION: &str = r"{\rtf1{\*\revtbl {Unknown;}{Bob;}}kept{\deleted\revauthdel1 gone}\par}"; + +#[test] +fn pasted_rtf_deletions_stay_deleted_under_track_changes() { + // Gap 19: with Track Changes on the paste is an insertion, and text deleted in the source is + // inserted-then-deleted (struck), never live text. + use wordcraft_doc::RevisionKind::{Delete, Insert}; + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "x"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": RTF_WITH_DELETION})); + assert_eq!(rev_kinds_at(&s, 1), (Some(Insert), None), "kept"); + assert_eq!(rev_kinds_at(&s, 5), (Some(Insert), Some(Delete)), "gone"); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "xkept"); +} + +#[test] +fn paste_merge_formatting_keeps_rtf_deletions() { + use wordcraft_doc::RevisionKind::Delete; + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "x"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.pasteMerge", json!({"rtf": RTF_WITH_DELETION})); + assert_eq!(rev_kinds_at(&s, 1), (None, None), "kept"); + assert_eq!(rev_kinds_at(&s, 5), (None, Some(Delete)), "gone"); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "xkept"); +} + #[test] fn pasted_rtf_sections_link_their_headers_to_the_previous() { // #1378: the RTF's own header part ids mean nothing in this document; a pasted section diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 2e60afaea..feb78a0b4 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -11,7 +11,7 @@ use wordcraft_doc::props::{ }; use wordcraft_doc::section::NumFormat; use wordcraft_doc::styles::{Style, StyleKind}; -use wordcraft_doc::{Align, Block, Blocks, Document, Paragraph, Rgb, Table, para_block}; +use wordcraft_doc::{Align, Block, Blocks, Document, Paragraph, Revision, RevisionKind, Rgb, Table, para_block}; /// Font used for code spans and code blocks. pub const MONO_FONT: &str = "Courier New"; @@ -37,6 +37,17 @@ pub struct Fmt { pub font: Option, /// Background (highlight / shading). pub background: Option, + /// Tracked insertion of this text (formats that keep revisions: RTF). + pub ins: Option, + /// Tracked deletion of this text. Only [`from_doc_tracked`] keeps deleted text in a flow. + pub del: Option, +} + +/// Who made a tracked change and when (ISO 8601; empty = unknown). +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct RevMark { + pub author: String, + pub date: String, } /// An embedded picture. @@ -386,9 +397,42 @@ fn append(p: &mut Paragraph, text: &str, props: &CharProps, obj: Option { doc: &'a mut Document, + /// Revisions already in `doc` by (kind, author, date), so each change is added once. + revs: std::collections::HashMap<(u8, RevMark), u32>, } -impl Builder<'_> { +fn rev_key(kind: RevisionKind) -> u8 { + match kind { + RevisionKind::Insert => 0, + RevisionKind::Delete => 1, + RevisionKind::Format => 2, + } +} + +impl<'a> Builder<'a> { + fn new(doc: &'a mut Document) -> Builder<'a> { + let mut revs = std::collections::HashMap::new(); + for (i, r) in doc.revisions.iter().enumerate() { + if r.move_name.is_none() { + let mark = RevMark { author: r.author.clone(), date: r.date.clone() }; + revs.entry((rev_key(r.kind), mark)).or_insert(i as u32); + } + } + Builder { doc, revs } + } + + /// The document revision for a tracked change, added the first time it is seen. + fn revision(&mut self, kind: RevisionKind, m: &RevMark) -> u32 { + let key = (rev_key(kind), m.clone()); + if let Some(i) = self.revs.get(&key) { + return *i; + } + let i = u32::try_from(self.doc.revisions.len()).unwrap_or(u32::MAX); + self.doc.revisions.push(Revision::new(kind, m.author.clone(), m.date.clone())); + self.revs.insert(key, i); + i + } + fn para(&mut self, p: &Para, list_num: Option) -> Paragraph { let mut out = Paragraph::new(); let style = match p.kind { @@ -429,6 +473,8 @@ impl Builder<'_> { Inline::Text(t, f) => { let t = clean_text(t); let mut cp = char_props(f); + cp.ins = f.ins.as_ref().map(|m| self.revision(RevisionKind::Insert, m)); + cp.del = f.del.as_ref().map(|m| self.revision(RevisionKind::Delete, m)); if p.kind == Kind::Code && cp.font.as_deref() == Some(MONO_FONT) { cp.font = None; } @@ -606,7 +652,7 @@ pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document if uses_code { doc.styles.upsert(code_style()); } - let body = Builder { doc: &mut doc }.blocks(&flow.blocks, 0); + let body = Builder::new(&mut doc).blocks(&flow.blocks, 0); doc.body = body; let m = &flow.meta; doc.core.title = m.title.clone(); @@ -622,7 +668,7 @@ pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document /// `blocks` as document blocks of `doc` (their pictures go into its media), for a header, /// footer or other part an importer adds itself. pub fn part_blocks(doc: &mut Document, blocks: &[FBlock]) -> Blocks { - let mut out = Builder { doc }.blocks(blocks, 0); + let mut out = Builder::new(doc).blocks(blocks, 0); if out.is_empty() { out.push(para_block(Paragraph::new())); } @@ -690,9 +736,19 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt { size: e.size, font: if code || in_code { None } else { e.font.clone() }, background: e.highlight.and_then(|h| h.rgb()).or(e.shading), + ins: None, + del: None, } } +/// The insertion and deletion `cp` tracks, as flow marks. +fn rev_marks(doc: &Document, cp: &CharProps) -> (Option, Option) { + let mark = |i: Option| { + i.map(|i| doc.revisions.get(i as usize).map(|r| RevMark { author: r.author.clone(), date: r.date.clone() }).unwrap_or_default()) + }; + (mark(cp.ins), mark(cp.del)) +} + /// Paragraph kind and list membership of a document paragraph. pub fn para_kind(doc: &Document, p: &Paragraph) -> (Kind, Option) { let rp = doc.styles.resolve_para(&p.props); @@ -734,6 +790,12 @@ fn media_ext(key: &str, data: &[u8]) -> String { /// A document paragraph as flow paragraphs: a page break character inside the text ends the /// paragraph, and the text after it starts a new one that carries `page_break`. pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { + flow_paras_in(doc, p, false) +} + +/// [`flow_paras`]; `tracked` keeps tracked changes: deleted text stays, and text carries its +/// insertion and deletion marks. +fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { let (kind, list) = para_kind(doc, p); let mut done: Vec = Vec::new(); let mut out = Para { @@ -750,8 +812,13 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { let mut k = 0usize; for (range, props) in p.run_ranges() { let Some(text) = p.text.get(range.clone()) else { continue }; + // Deleted objects and page breaks stay out even when tracking: the flow has no marks for them. let hidden = props.hidden.unwrap_or(false) || props.del.is_some(); - let f = fmt_of(doc, props, in_code); + let hidden_text = props.hidden.unwrap_or(false) || (props.del.is_some() && !tracked); + let mut f = fmt_of(doc, props, in_code); + if tracked { + (f.ins, f.del) = rev_marks(doc, props); + } let mut buf = String::new(); for c in text.chars() { if c == PAGE_BREAK { @@ -827,7 +894,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { None => {} } } - if !hidden { + if !hidden_text { out.push_text(&buf, &f); } } @@ -835,16 +902,16 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { done } -fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize) -> Vec { +fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: bool) -> Vec { let mut out = Vec::new(); for b in bl { match &**b { - Block::Para(p) => out.extend(flow_paras(doc, p).into_iter().map(FBlock::Para)), - Block::Table(t) if depth < MAX_DEPTH => out.push(FBlock::Table(flow_table(doc, t, depth))), + Block::Para(p) => out.extend(flow_paras_in(doc, p, tracked).into_iter().map(FBlock::Para)), + Block::Table(t) if depth < MAX_DEPTH => out.push(FBlock::Table(flow_table(doc, t, depth, tracked))), Block::Table(t) => { for r in &t.rows { for c in &r.cells { - out.extend(flow_blocks(doc, &c.blocks, depth)); + out.extend(flow_blocks(doc, &c.blocks, depth, tracked)); } } } @@ -853,7 +920,7 @@ fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize) -> Vec { out } -fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable { +fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable { let mut rows = Vec::new(); for (ri, r) in t.rows.iter().enumerate() { let header = r.props.header || (ri == 0 && t.props.look.header_row && t.rows.len() > 1); @@ -871,7 +938,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable { } } cells.push(Cell { - blocks: flow_blocks(doc, &c.blocks, depth + 1), + blocks: flow_blocks(doc, &c.blocks, depth + 1, tracked), colspan: c.span() as u32, rowspan, covered: c.props.vmerge == VMerge::Continue, @@ -891,9 +958,19 @@ fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable { /// The document body as a flow. pub fn from_doc(doc: &Document) -> Flow { + flow_of(doc, false) +} + +/// [`from_doc`] keeping tracked changes: deleted text stays, and text carries its insertion and +/// deletion marks ([`Fmt::ins`], [`Fmt::del`]), for formats that can write revisions (RTF). +pub fn from_doc_tracked(doc: &Document) -> Flow { + flow_of(doc, true) +} + +fn flow_of(doc: &Document, tracked: bool) -> Flow { let c = &doc.core; Flow { - blocks: flow_blocks(doc, &doc.body, 0), + blocks: flow_blocks(doc, &doc.body, 0, tracked), meta: Meta { title: c.title.clone(), author: c.creator.clone(), diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 8f5fede5b..d5ac7db4f 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -89,6 +89,8 @@ struct Writer { t: Tables, /// List definitions: per list, the ordered flag per level. lists: Vec<[bool; 9]>, + /// Authors of tracked changes (`\revtbl` entries after "Unknown"), when the text has any. + authors: Option>, } const HEAD_SIZES: [f32; 6] = [20.0, 16.0, 14.0, 12.0, 12.0, 11.0]; @@ -104,8 +106,36 @@ fn style_num(k: Kind) -> Option { } impl Writer { + /// `\revtbl` index of a tracked change's author (0 = "Unknown"). + fn author(&mut self, a: &str) -> usize { + let authors = self.authors.get_or_insert_with(Vec::new); + let a = a.trim(); + if a.is_empty() { + return 0; + } + match authors.iter().position(|x| x == a) { + Some(i) => i + 1, + None => { + authors.push(a.to_string()); + authors.len() + } + } + } + fn run(&mut self, t: &str, f: &Fmt, out: &mut String) { let mut ctl = String::new(); + if let Some(m) = &f.ins { + ctl.push_str(&format!("\\revised\\revauth{}", self.author(&m.author))); + if let Some(d) = iso_to_dttm(&m.date) { + ctl.push_str(&format!("\\revdttm{}", d as i32)); + } + } + if let Some(m) = &f.del { + ctl.push_str(&format!("\\deleted\\revauthdel{}", self.author(&m.author))); + if let Some(d) = iso_to_dttm(&m.date) { + ctl.push_str(&format!("\\revdttmdel{}", d as i32)); + } + } if f.bold { ctl.push_str("\\b"); } @@ -397,11 +427,65 @@ fn rtf_date(s: &str) -> Option { Some(format!("\\yr{y}\\mo{mo}\\dy{d}\\hr{h}\\min{mi}")) } +fn days_in_month(y: u32, m: u32) -> u32 { + match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400) => 29, + 2 => 28, + _ => 0, + } +} + +/// Day of the week, 0 = Sunday (Sakamoto's method). +fn weekday(y: u32, m: u32, d: u32) -> u32 { + const T: [u32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = if m < 3 { y.saturating_sub(1) } else { y }; + (y + y / 4 - y / 100 + y / 400 + T.get(m.saturating_sub(1) as usize).copied().unwrap_or(0) + d) % 7 +} + +/// An RTF revision date (DTTM, RTF 1.9.1 "Revision Marks": minute in bits 0–5, hour 6–10, day +/// 11–15, month 16–19, year − 1900 20–28, weekday 29–31) as ISO 8601 (`2024-03-15T10:30:00Z`). +/// Zero or an impossible date is no date. DTTM has no time zone or seconds: times are read as +/// UTC (`Z`). +pub fn dttm_to_iso(v: u32) -> Option { + let (mi, h, d, mo, y) = (v & 0x3F, (v >> 6) & 0x1F, (v >> 11) & 0x1F, (v >> 16) & 0xF, 1900 + ((v >> 20) & 0x1FF)); + if v == 0 || mi > 59 || h > 23 || d == 0 || d > days_in_month(y, mo) { + return None; + } + Some(format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:00Z")) +} + +/// An ISO 8601 date (`YYYY-MM-DD`, optionally `THH:MM…`; seconds and zone ignored) as an RTF +/// DTTM; `None` when it isn't one or falls outside DTTM's years (1900–2411). DTTM has no time +/// zone or seconds: the clock time is written as given (WordCraft keeps UTC, `Z`), and seconds +/// and any offset are dropped. +pub fn iso_to_dttm(s: &str) -> Option { + let num = |a: usize, b: usize| s.get(a..b).filter(|t| t.bytes().all(|c| c.is_ascii_digit())).and_then(|t| t.parse::().ok()); + let sep = |i: usize, c: &[&str]| s.get(i..i + 1).is_some_and(|t| c.contains(&t)); + let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + if !sep(4, &["-"]) || !sep(7, &["-"]) { + return None; + } + let (h, mi) = if s.len() > 10 { + if !sep(10, &["T", " "]) || !sep(13, &[":"]) { + return None; + } + (num(11, 13)?, num(14, 16)?) + } else { + (0, 0) + }; + if !(1900..=2411).contains(&y) || mi > 59 || h > 23 || d == 0 || d > days_in_month(y, mo) { + return None; + } + Some(mi | (h << 6) | (d << 11) | (mo << 16) | ((y - 1900) << 20) | (weekday(y, mo, d) << 29)) +} + /// RTF text of a document. pub fn export(doc: &Document) -> String { - let flow = model::from_doc(doc); + let flow = model::from_doc_tracked(doc); let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string()); - let mut w = Writer { t: Tables { fonts: vec![base_font], colors: Vec::new() }, lists: Vec::new() }; + let mut w = Writer { t: Tables { fonts: vec![base_font], colors: Vec::new() }, lists: Vec::new(), authors: None }; let mut body = String::new(); w.blocks(&flow.blocks, false, "\\par", &mut body, 0); export_with(&flow.meta, &w, &body, &doc.last_section) @@ -441,6 +525,13 @@ fn export_with(m: &Meta, w: &Writer, body: &str, sec: &wordcraft_doc::SectionPro } out.push_str("}\n"); } + if let Some(authors) = &w.authors { + out.push_str("{\\*\\revtbl {Unknown;}"); + for a in authors { + out.push_str(&format!("{{{};}}", esc(&a.replace(';', ",")))); + } + out.push_str("}\n"); + } out.push_str("{\\info"); for (k, v) in [("title", &m.title), ("author", &m.author), ("subject", &m.subject), ("keywords", &m.keywords), ("doccomm", &m.description)] { if !v.is_empty() { @@ -482,6 +573,23 @@ enum Dest { FldInst, Pict, Bookmark, + /// The author table of tracked changes (`\revtbl`). + RevTbl, +} + +/// Most authors read from a `\revtbl` (hostile input). +const MAX_REV_AUTHORS: usize = 10_000; + +/// Tracked-change character properties: `\revised` / `\deleted`, their authors (`\revtbl` +/// indices) and dates (DTTM). +#[derive(Clone, Copy, Debug, Default)] +struct RevSt { + ins: bool, + del: bool, + auth: i32, + authdel: i32, + dttm: u32, + dttmdel: u32, } /// Which header or footer a `\header…`/`\footer…` group is: footer or header, and the pages @@ -519,6 +627,7 @@ struct Output { struct St { dest: Dest, fmt: Fmt, + rev: RevSt, uc: usize, hidden: bool, // Paragraph properties. @@ -552,6 +661,7 @@ impl Default for St { St { dest: Dest::Body, fmt: Fmt::default(), + rev: RevSt::default(), uc: 1, hidden: false, align: None, @@ -747,6 +857,9 @@ struct Reader { pict_sx: f32, pict_sy: f32, bkmk: Option<(usize, String)>, + /// Authors of tracked changes (`\revtbl`), and the entry being read. + rev_authors: Vec, + rev_name: String, skip_chars: usize, pending_high: Option, // Output. @@ -906,6 +1019,7 @@ impl Reader { match closing { Dest::FontTbl if !self.font_name.is_empty() => self.commit_font(), Dest::StyleSheet if !self.style_name.is_empty() => self.commit_style(), + Dest::RevTbl if !self.rev_name.trim().is_empty() => self.commit_author(), Dest::Info(k) => { let t = std::mem::take(&mut self.info_text).trim().to_string(); match k { @@ -941,6 +1055,20 @@ impl Reader { } } + fn commit_author(&mut self) { + let name = std::mem::take(&mut self.rev_name).trim().to_string(); + if self.rev_authors.len() < MAX_REV_AUTHORS { + self.rev_authors.push(name); + } + } + + /// The tracked-change mark for `\revtbl` author index `i` and DTTM `dttm`; an index outside + /// the table is "Unknown". + fn rev_mark(&self, i: i32, dttm: u32) -> model::RevMark { + let author = usize::try_from(i).ok().and_then(|i| self.rev_authors.get(i)).cloned().unwrap_or_else(|| "Unknown".to_string()); + model::RevMark { author, date: dttm_to_iso(dttm).unwrap_or_default() } + } + fn commit_font(&mut self) { let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string(); if !name.is_empty() && self.fonts.len() < 4096 { @@ -989,7 +1117,14 @@ impl Reader { if self.st.hidden { return; } - let f = self.st.fmt.clone(); + let mut f = self.st.fmt.clone(); + let r = self.st.rev; + if r.ins { + f.ins = Some(self.rev_mark(r.auth, r.dttm)); + } + if r.del { + f.del = Some(self.rev_mark(r.authdel, r.dttmdel)); + } self.para.push_text(s, &f); self.para_has = true; } @@ -1055,6 +1190,15 @@ impl Reader { n.push_str(s); } } + Dest::RevTbl => { + for c in s.chars() { + if c == ';' { + self.commit_author(); + } else if self.rev_name.len() < 256 { + self.rev_name.push(c); + } + } + } Dest::Skip | Dest::ListTable | Dest::ListOverride => {} } } @@ -1258,6 +1402,7 @@ impl Reader { "keywords" if self.in_info() => Some(Dest::Info(4)), "doccomm" if self.in_info() => Some(Dest::Info(5)), "listtable" => Some(Dest::ListTable), + "revtbl" => Some(Dest::RevTbl), "listoverridetable" => Some(Dest::ListOverride), "fldinst" => Some(Dest::FldInst), // Transparent wrapper: read the nested pict without changing the destination. @@ -1280,10 +1425,8 @@ impl Reader { | "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst" | "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages" | "nofwords" | "nofchars" | "edmins" | "vern" | "themedata" | "colorschememapping" | "latentstyles" | "datastore" | "xmlnstbl" - | "rsidtbl" | "generator" | "mmathPr" | "pgdsctbl" | "filetbl" | "revtbl" | "listpicture" | "userprops" | "docvar" | "objdata" - | "fontemb" | "fontfile" | "panose" | "falt" | "mhtmltag" | "htmltag" | "pnseclvl" | "background" | "sp" | "pn" | "pgptbl" => { - Some(Dest::Skip) - } + | "rsidtbl" | "generator" | "mmathPr" | "pgdsctbl" | "filetbl" | "listpicture" | "userprops" | "docvar" | "objdata" | "fontemb" + | "fontfile" | "panose" | "falt" | "mhtmltag" | "htmltag" | "pnseclvl" | "background" | "sp" | "pn" | "pgptbl" => Some(Dest::Skip), "creatim" if self.in_info() => { self.in_creatim = true; Some(Dest::Skip) @@ -1414,6 +1557,9 @@ impl Reader { } return; } + // Author names take Unicode escapes like the other text destinations. + Dest::RevTbl if !matches!(w, "u" | "uc") => return, + Dest::RevTbl => {} Dest::Pict => { match w { "pngblip" => self.pict_kind = 1, @@ -1468,13 +1614,22 @@ impl Reader { "uc" => self.st.uc = pv.clamp(0, 8) as usize, "plain" => { self.st.fmt = Fmt { link: self.st.fmt.link.clone(), ..Default::default() }; + self.st.rev = RevSt::default(); self.st.hidden = false; } "pard" => { let field = self.st.field; - let (dest, fmt, uc, hidden) = (self.st.dest, self.st.fmt.clone(), self.st.uc, self.st.hidden); - self.st = St { dest, fmt, uc, hidden, field, ..Default::default() }; + // Revision marks are character formatting, which `\pard` leaves alone. + let (dest, fmt, rev, uc, hidden) = (self.st.dest, self.st.fmt.clone(), self.st.rev, self.st.uc, self.st.hidden); + self.st = St { dest, fmt, rev, uc, hidden, field, ..Default::default() }; } + "revised" => self.st.rev.ins = on, + "deleted" => self.st.rev.del = on, + "revauth" => self.st.rev.auth = pv, + "revauthdel" => self.st.rev.authdel = pv, + // DTTMs are 32-bit patterns, written signed or unsigned. + "revdttm" => self.st.rev.dttm = pv as u32, + "revdttmdel" => self.st.rev.dttmdel = pv as u32, "b" => self.st.fmt.bold = on, "i" => self.st.fmt.italic = on, "ul" | "uld" | "uldash" | "uldashd" | "uldashdd" | "uldb" | "ulth" | "ulw" | "ulwave" | "ulhwave" | "ulldash" | "ulthd" => { @@ -1720,6 +1875,12 @@ fn parse_full(bytes: &[u8]) -> Result { let param = if k > d0 { let v = std::str::from_utf8(bytes.get(d0..k).unwrap_or(b"0")).ok().and_then(|s| s.parse::().ok()).unwrap_or(0); let v = if neg { -v } else { v }; + // A date (DTTM) is a 32-bit pattern some writers put unsigned. + let v = if matches!(word, "revdttm" | "revdttmdel") && v > i64::from(i32::MAX) && v <= i64::from(u32::MAX) { + v - (1i64 << 32) + } else { + v + }; Some(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32) } else { if neg { @@ -2228,6 +2389,136 @@ mod tests { assert_eq!(texts(&f).last().map(String::as_str), Some("Normal:Café")); } + /// A hand-written RTF with tracked changes: Alice inserts "added ", Bob deletes "gone", and + /// "both" is inserted by Alice then deleted by Bob. + pub(crate) const TRACKED: &[u8] = br"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}{Bob;}} +\pard Keep {\revised\revauth1\revdttm-1480361314 added }text{\deleted\revauthdel2\revdttmdel-2016341499 gone} end{\revised\revauth1\revdttm-1480361314\deleted\revauthdel2\revdttmdel-2016341499 both}.\par}"; + + /// (text, insertion (author, date), deletion (author, date)) of each run of the first paragraph. + #[allow(clippy::type_complexity)] + fn tracked_runs(doc: &Document) -> Vec<(String, Option<(String, String)>, Option<(String, String)>)> { + let rev = |i: Option, kind: wordcraft_doc::RevisionKind| { + i.map(|i| { + let r = doc.revisions.get(i as usize).unwrap(); + assert_eq!(r.kind, kind); + (r.author.clone(), r.date.clone()) + }) + }; + let Some(wordcraft_doc::Block::Para(p)) = doc.body.first().map(|b| &**b) else { panic!("no paragraph") }; + p.run_ranges() + .map(|(r, cp)| { + (p.text[r].to_string(), rev(cp.ins, wordcraft_doc::RevisionKind::Insert), rev(cp.del, wordcraft_doc::RevisionKind::Delete)) + }) + .collect() + } + + fn expected_tracked() -> Vec<(String, Option<(String, String)>, Option<(String, String)>)> { + let a = Some(("Alice".to_string(), "2024-03-15T10:30:00Z".to_string())); + let b = Some(("Bob".to_string(), "2025-01-02T08:05:00Z".to_string())); + vec![ + ("Keep ".into(), None, None), + ("added ".into(), a.clone(), None), + ("text".into(), None, None), + (" gone".into(), None, b.clone()), + (" end".into(), None, None), + (" both".into(), a, b), + (".".into(), None, None), + ] + } + + #[test] + fn tracked_changes_import_with_authors_and_dates() { + let doc = import(TRACKED).unwrap(); + assert_eq!(tracked_runs(&doc), expected_tracked()); + // One revision per (kind, author, date). + assert_eq!(doc.revisions.len(), 2); + } + + #[test] + fn tracked_changes_survive_export_and_import() { + let doc = import(TRACKED).unwrap(); + let rtf = export(&doc); + assert!(rtf.contains("{\\*\\revtbl {Unknown;}{Alice;}{Bob;}}"), "{rtf}"); + assert!(rtf.contains("\\deleted\\revauthdel2\\revdttmdel-2016341499"), "{rtf}"); + let back = import(rtf.as_bytes()).unwrap(); + assert_eq!(tracked_runs(&back), expected_tracked()); + } + + #[test] + fn plain_text_export_still_drops_deleted_text() { + // Only RTF keeps the deletions: the shared flow for other formats leaves them out. + let doc = import(TRACKED).unwrap(); + let flow = model::from_doc(&doc); + assert_eq!(texts(&flow), vec!["Normal:Keep added text end."]); + } + + #[test] + fn dttm_converts_to_and_from_iso() { + assert_eq!(dttm_to_iso(-1480361314i32 as u32).as_deref(), Some("2024-03-15T10:30:00Z")); + assert_eq!(dttm_to_iso(-2016341499i32 as u32).as_deref(), Some("2025-01-02T08:05:00Z")); + assert_eq!(iso_to_dttm("2024-03-15T10:30:00Z"), Some(-1480361314i32 as u32)); + assert_eq!(iso_to_dttm("2025-01-02T08:05:59.123+02:00"), Some(-2016341499i32 as u32)); + assert_eq!(iso_to_dttm("2000-02-29T00:00:00Z").and_then(dttm_to_iso).as_deref(), Some("2000-02-29T00:00:00Z")); + // Zero, out-of-range fields and impossible days are no date. + assert_eq!(dttm_to_iso(0), None); + assert_eq!(dttm_to_iso(13 << 16 | 1 << 11 | 100 << 20), None); + assert_eq!(dttm_to_iso(2 << 16 | 30 << 11 | 101 << 20), None); + assert_eq!(dttm_to_iso(1 << 16 | 1 << 11 | 100 << 20 | 60), None); + assert_eq!(iso_to_dttm(""), None); + assert_eq!(iso_to_dttm("1899-12-31T00:00:00Z"), None); + assert_eq!(iso_to_dttm("2024-02-30T00:00:00Z"), None); + assert_eq!(iso_to_dttm("2024-1é-01T00:00:00Z"), None); + assert_eq!(iso_to_dttm("2024-03-15"), iso_to_dttm("2024-03-15T00:00:00Z")); + } + + #[test] + fn hostile_revision_tables_and_indices() { + // An author index past the table, a negative one, and a malformed table. + let rtf = br"{\rtf1{\*\revtbl {Unknown;}{Ann}{;}{\b;}}{\revised\revauth99999999 a}{\deleted\revauthdel-5\revdttmdel4294967295 b}{\revised\revauth1 c}\par}"; + let doc = import(rtf).unwrap(); + let runs = tracked_runs(&doc); + assert_eq!(runs.first().and_then(|r| r.1.clone()), Some(("Unknown".to_string(), String::new()))); + assert_eq!(runs.get(1).and_then(|r| r.2.clone()), Some(("Unknown".to_string(), String::new()))); + assert_eq!(runs.get(2).and_then(|r| r.1.clone()), Some(("Ann".to_string(), String::new()))); + // A huge table is capped, and indices past the cap are "Unknown". + let mut rtf = String::from("{\\rtf1{\\*\\revtbl "); + for i in 0..20_000 { + rtf.push_str(&format!("{{A{i};}}")); + } + rtf.push_str("}{\\revised\\revauth15000 x}{\\revised\\revauth9999 y}\\par}"); + let doc = import(rtf.as_bytes()).unwrap(); + let runs = tracked_runs(&doc); + assert_eq!(runs.first().and_then(|r| r.1.clone()).map(|a| a.0).as_deref(), Some("Unknown")); + assert_eq!(runs.get(1).and_then(|r| r.1.clone()).map(|a| a.0).as_deref(), Some("A9999")); + // Unterminated table and revision words outside the body never panic. + for bad in [&br"{\rtf1{\*\revtbl {Al"[..], br"{\rtf1{\*\revtbl \revised\deleted{x}}y\par}", br"{\rtf1\revauth\revdttm\deleted"] { + let _ = import(bad); + } + } + + #[test] + fn unicode_author_names_survive_import_and_round_trip() { + // `\uN` in the author table: "José" and "Дмитрий" (not "Jos?" / "???????"). + let rtf = br"{\rtf1\uc1{\*\revtbl {Unknown;}{Jos\u233?;}{\u1044?\u1084?\u1080?\u1090?\u1088?\u1080?\u1081?;}}{\revised\revauth1 a}{\deleted\revauthdel2 b}\par}"; + let authors = |d: &Document| tracked_runs(d).into_iter().map(|r| (r.1.map(|a| a.0), r.2.map(|a| a.0))).collect::>(); + let want = vec![(Some("José".to_string()), None), (None, Some("Дмитрий".to_string()))]; + let doc = import(rtf).unwrap(); + assert_eq!(authors(&doc), want); + let back = import(export(&doc).as_bytes()).unwrap(); + assert_eq!(authors(&back), want); + } + + #[test] + fn pard_keeps_revision_marks() { + // Revision marks are character formatting: `\pard` resets paragraph formatting only. + let rtf = br"{\rtf1{\*\revtbl {Unknown;}{Bob;}}\deleted\revauthdel1 gone\par\pard more\par}"; + let doc = import(rtf).unwrap(); + let Some(wordcraft_doc::Block::Para(p)) = doc.body.get(1).map(|b| &**b) else { panic!("no second paragraph") }; + assert_eq!(p.text, "more"); + let del = p.props_of_char(0).del.and_then(|i| doc.revisions.get(i as usize)); + assert_eq!(del.map(|r| (r.kind, r.author.as_str())), Some((wordcraft_doc::RevisionKind::Delete, "Bob"))); + } + #[test] fn sections_keep_their_page_columns_numbering_and_headers() { use wordcraft_doc::section::{NumFormat, SectionStart}; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 811881cbc..5272480c1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: ODT sections on import, #1379; previously trivial: PDF export: Hebrew and Arabic text extracts as whole words, #1383; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: RTF tracked insertions and deletions read and written, gap 19; previously trivial: ODT sections on import, #1379; previously trivial: PDF export: Hebrew and Arabic text extracts as whole words, #1383; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -25,8 +25,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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 or encrypted files are dropped or refused. No writer | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | +| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379). No footnotes, comments, tracked changes, fields or RTL | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, revisions, section export, RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | @@ -54,7 +54,7 @@ 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`, modern threaded comments (`w16cex`) partly | | 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. RTF/ODT/PDF export the result (moved-away text hidden like deletions) | +| 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 | | 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) | | 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 | | Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges | @@ -170,6 +170,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | | 2026-10-14 | trivial | PDF export: All Caps and Small Caps text extracts in capitals, as displayed, and no longer turns later capitals in uncapitalised text lowercase (#1473) | +| 2026-10-14 | trivial | RTF reads and writes tracked insertions and deletions with their authors and dates (gap 19); deleted text no longer opens as normal text | | 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) | | 2026-10-14 | trivial | PDF export writes right-to-left runs' glyphs in visual order, so Chrome, Edge (PDFium) and poppler extract Hebrew and Arabic as whole words (vowelled words keep their letters in order but may extract with spaces inside), and Ctrl+F finds them (#1383). `/ReversedChars` (for strict file-order readers) covers plain runs and vowelled runs whose marks split into their own glyphs, not runs with ligatures or mixed directions | | 2026-10-14 | trivial | HTML: `` bold, cell `text-align` (own or inherited) reaches the cell's paragraphs; HTML, Markdown and plain text open on the region's paper, in the app and `wordcraft-cli` (#1381) | diff --git a/docs/gaps.md b/docs/gaps.md index bd171d39e..c10057080 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-14 · **Change:** trivial (#19 narrowed: ODT sections on import, #1379; previously trivial: #16: PDF export's right-to-left text extracts as whole words, #1383; previously trivial: Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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-14 · **Change:** trivial (RTF tracked insertions and deletions, gap 19; previously trivial: #19 narrowed: ODT sections on import, #1379; previously trivial: #16: PDF export's right-to-left text extracts as whole words, #1383; previously trivial: Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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. @@ -33,7 +33,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions, sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF insertions and deletions done), sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | @@ -84,6 +84,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word | | 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | | 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | +| 2026-10-14 | trivial | Gap 19: RTF tracked insertions and deletions read and written; RTF paragraph-mark and formatting revisions, and ODT revisions, remain | | 2026-10-14 | trivial | #19 narrowed: ODT import keeps sections (master-page changes, columned `text:section`s, page-numbering restarts) (#1379) | | 2026-10-14 | trivial | #16: PDF export writes right-to-left glyphs in visual order, so Hebrew and Arabic extract, copy and search as whole words (#1383) | | 2026-10-14 | trivial | RTF import keeps sections (#1378): gap 19 narrowed to RTF section export and ODT sections | From b4f6b603acf8159c79c78be25d2c99ca32e32524 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:28:39 +0200 Subject: [PATCH 084/199] Canvas: alignment guides while dragging pictures and shapes (#1519) * feat-smart-guides: WIP * Canvas: alignment guides while dragging pictures and shapes Co-Authored-By: Claude Opus 5.5 * Alignment guides: Alt during a drag doesn't open keytips; translated options Co-Authored-By: Claude Opus 5.5 * vi: alignment-guide strings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/ui-egui/src/guides.rs | 193 +++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/keytips.rs | 6 + crates/ui-egui/src/lib.rs | 34 +++++ crates/ui-egui/src/objects.rs | 229 +++++++++++++++++++++++++++- crates/ui-egui/src/options.rs | 32 ++++ crates/ui-egui/src/theme.rs | 5 + docs/control-protocol.md | 2 +- docs/ui-parity.md | 7 +- 28 files changed, 545 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/guides.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3e5f4db00..861470e63 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: RTF tracked changes, gap 19; 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: 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: alignment guides while dragging pictures and shapes; 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: 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. @@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake | | 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening | +| 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides | | 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | | 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified | | 2026-10-14 | HTML import (#1381): header cells (``) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it | @@ -336,6 +337,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) | | 2026-10-14 | trivial | Progress log: RTF tracked insertions and deletions (gap 19) | +| 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes | | 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | | 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) | | 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) | diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs new file mode 100644 index 000000000..d4b55781f --- /dev/null +++ b/crates/ui-egui/src/guides.rs @@ -0,0 +1,193 @@ +//! Alignment guides: while a picture, shape or text box is dragged, its left, centre or right +//! edge (and top, middle or bottom) snaps to the page's edges and centre, the margins and the +//! edges and centres of the other floating objects on its page, and a thin line across the page +//! shows each place it snapped to. WordCraft's own design (Word for Mac shows no guides). +//! +//! The maths here is pure (points in, points out) so it's tested without egui; `objects.rs` +//! gathers the candidates and draws the lines. + +use wordcraft_geom::Rect; + +/// How near (screen points) an edge or centre must come to a guide to snap to it. +pub const SNAP_SCREEN: f32 = 6.0; + +/// Which way a guide runs: `X` is a vertical line at `at` (lefts, centres and rights snap to it), +/// `Y` a horizontal one (tops, middles and bottoms). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Axis { + X, + Y, +} + +/// A place to snap to on the page, points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Guide { + pub axis: Axis, + pub at: f32, +} + +impl Guide { + pub fn x(at: f32) -> Guide { + Guide { axis: Axis::X, at } + } + pub fn y(at: f32) -> Guide { + Guide { axis: Axis::Y, at } + } +} + +/// What snapping does to a dragged rect: move it by (`dx`, `dy`) points, and show `lines`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Snap { + pub dx: f32, + pub dy: f32, + pub lines: Vec, +} + +/// Snap `moving` (the dragged object's bounds, points) to the nearest of `candidates` within +/// `threshold` points, x and y independently: its left, centre or right to an `X` guide, its top, +/// middle or bottom to a `Y` guide. Nearest wins; on a tie, the first candidate. The lines are +/// every guide an edge or centre sits on once moved. +pub fn snap(moving: Rect, candidates: &[Guide], threshold: f32) -> Snap { + if !threshold.is_finite() || threshold < 0.0 { + return Snap::default(); + } + let xs = [moving.x, moving.x + moving.w / 2.0, moving.x + moving.w]; + let ys = [moving.y, moving.y + moving.h / 2.0, moving.y + moving.h]; + let mut out = Snap::default(); + for (axis, points) in [(Axis::X, xs), (Axis::Y, ys)] { + let Some(d) = nearest(&points, candidates, axis, threshold) else { continue }; + match axis { + Axis::X => out.dx = d, + Axis::Y => out.dy = d, + } + for g in candidates.iter().filter(|g| g.axis == axis && g.at.is_finite()) { + if points.iter().any(|p| (p + d - g.at).abs() <= LANDED) && !out.lines.contains(g) { + out.lines.push(*g); + } + } + } + out +} + +/// How near (points) an edge must be to a guide after snapping to count as on it. +const LANDED: f32 = 0.01; + +/// The shortest move (within `threshold`) that puts one of `points` on an `axis` guide. +fn nearest(points: &[f32], candidates: &[Guide], axis: Axis, threshold: f32) -> Option { + let mut best: Option = None; + for g in candidates.iter().filter(|g| g.axis == axis && g.at.is_finite()) { + for p in points.iter().filter(|p| p.is_finite()) { + let d = g.at - p; + if d.abs() <= threshold && best.is_none_or(|b| d.abs() < b.abs()) { + best = Some(d); + } + } + } + best +} + +/// The guides of a page `w` × `h` points with text area `body`, and of the other floating objects +/// on it (their bounds): edges and centres. +pub fn candidates(w: f32, h: f32, body: Rect, others: impl IntoIterator) -> Vec { + let mut g = Vec::new(); + let mut add = |r: Rect| { + for x in [r.x, r.x + r.w / 2.0, r.x + r.w] { + g.push(Guide::x(x)); + } + for y in [r.y, r.y + r.h / 2.0, r.y + r.h] { + g.push(Guide::y(y)); + } + }; + add(Rect::new(0.0, 0.0, w, h)); + add(body); + for r in others { + add(r); + } + g.retain(|g| g.at.is_finite()); + g +} + +#[cfg(test)] +mod tests { + use super::*; + + const R: Rect = Rect { x: 100.0, y: 200.0, w: 50.0, h: 20.0 }; + + #[test] + fn edges_snap_to_guides() { + // Left edge 3 pt from x = 97: moves left onto it. + let s = snap(R, &[Guide::x(97.0)], 6.0); + assert_eq!((s.dx, s.dy), (-3.0, 0.0)); + assert_eq!(s.lines, vec![Guide::x(97.0)]); + // Right edge (150) to 154. + let s = snap(R, &[Guide::x(154.0)], 6.0); + assert_eq!(s.dx, 4.0); + // Bottom (220) to 218. + let s = snap(R, &[Guide::y(218.0)], 6.0); + assert_eq!((s.dx, s.dy), (0.0, -2.0)); + assert_eq!(s.lines, vec![Guide::y(218.0)]); + } + + #[test] + fn centres_snap_to_guides() { + // Centre 125, middle 210. + let s = snap(R, &[Guide::x(127.0), Guide::y(207.5)], 6.0); + assert_eq!((s.dx, s.dy), (2.0, -2.5)); + assert_eq!(s.lines, vec![Guide::x(127.0), Guide::y(207.5)]); + } + + #[test] + fn the_nearest_guide_wins() { + // Left (100) is 5 from 95; right (150) is 1 from 151: the right edge snaps. + let s = snap(R, &[Guide::x(95.0), Guide::x(151.0)], 6.0); + assert_eq!(s.dx, 1.0); + assert_eq!(s.lines, vec![Guide::x(151.0)]); + } + + #[test] + fn guides_beyond_the_threshold_are_ignored() { + let s = snap(R, &[Guide::x(93.0), Guide::y(230.0)], 6.0); + assert_eq!(s, Snap::default()); + // Exactly at the threshold still snaps. + assert_eq!(snap(R, &[Guide::x(94.0)], 6.0).dx, -6.0); + } + + #[test] + fn x_and_y_snap_independently() { + // Only y has a guide in reach: x stays put. + let s = snap(R, &[Guide::x(300.0), Guide::y(199.0)], 6.0); + assert_eq!((s.dx, s.dy), (0.0, -1.0)); + assert_eq!(s.lines, vec![Guide::y(199.0)]); + } + + #[test] + fn every_guide_an_edge_lands_on_is_shown() { + // Snapping the left edge to 98 also puts the right edge on 148. + let s = snap(R, &[Guide::x(98.0), Guide::x(148.0), Guide::x(148.0)], 6.0); + assert_eq!(s.dx, -2.0); + assert_eq!(s.lines, vec![Guide::x(98.0), Guide::x(148.0)], "each line once"); + } + + #[test] + fn nothing_to_snap_to() { + assert_eq!(snap(R, &[], 6.0), Snap::default()); + // Junk never moves anything. + let junk = [Guide::x(f32::NAN), Guide::y(f32::INFINITY)]; + assert_eq!(snap(R, &junk, 6.0), Snap::default()); + assert_eq!(snap(R, &[Guide::x(99.0)], f32::NAN), Snap::default()); + assert_eq!(snap(R, &[Guide::x(99.0)], -1.0), Snap::default()); + assert_eq!(snap(Rect::new(f32::NAN, 0.0, 1.0, 1.0), &[Guide::x(0.0)], 6.0).dx, 0.0); + } + + #[test] + fn candidates_are_page_margins_and_other_objects() { + let body = Rect::new(72.0, 72.0, 468.0, 648.0); + let g = candidates(612.0, 792.0, body, [Rect::new(10.0, 20.0, 30.0, 40.0)]); + for x in [0.0, 306.0, 612.0, 72.0, 540.0, 10.0, 25.0, 40.0] { + assert!(g.contains(&Guide::x(x)), "x {x}: {g:?}"); + } + for y in [0.0, 396.0, 792.0, 72.0, 720.0, 20.0, 40.0, 60.0] { + assert!(g.contains(&Guide::y(y)), "y {y}: {g:?}"); + } + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e867f457e..319a15f22 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2131,6 +2131,8 @@ Mirror indents Огледални отстъпи Overtype Заместване Overtype (typing replaces the characters after the caret) Заместване (въвежданият текст замества знаците след курсора) + Show alignment guides when moving pictures and shapes Показване на водачи за подравняване при преместване на картини и фигури + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Преместените обекти се прилепват към краищата и центъра на страницата, полетата и другите обекти. Задръжте Alt за свободно преместване. Page display Показване на страницата Paragraph marks Знаци за абзац Personalize Персонализиране diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 06b8341ab..5035211a1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1855,6 +1855,8 @@ No words yet. Add to Dictionary on a misspelled word puts it here. Zatím žádná slova. Sem se ukládají slova přidaná příkazem Přidat do slovníku u chybně napsaného slova. Overtype Přepisování Overtype (typing replaces the characters after the caret) Přepisování (psaný text nahrazuje znaky za kurzorem) + Show alignment guides when moving pictures and shapes Při přesouvání obrázků a tvarů zobrazovat zarovnávací vodítka + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Přesouvané objekty se přichytávají k okrajům a středu stránky, k okrajům textu a k dalším objektům. Podržením klávesy Alt je přesunete volně. Page display Zobrazení stránky Paragraph marks Značky odstavců Personalize Přizpůsobení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 38d822dc8..1f6ad51cb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1437,6 +1437,8 @@ No words yet. Add to Dictionary on a misspelled word puts it here. Noch keine Wörter. „Zum Wörterbuch hinzufügen“ bei einem falsch geschriebenen Wort legt es hier ab. Overtype Überschreiben Overtype (typing replaces the characters after the caret) Überschreiben (die Eingabe ersetzt die Zeichen nach der Einfügemarke) + Show alignment guides when moving pictures and shapes Ausrichtungslinien beim Verschieben von Bildern und Formen anzeigen + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Verschobene Objekte rasten an den Rändern und der Mitte der Seite, an den Seitenrändern und an anderen Objekten ein. Mit gedrückter Alt-Taste frei verschieben. Page display Seitenanzeige Paragraph marks Absatzmarken Personalize Personalisieren diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c2faf8e26..94409af75 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -126,6 +126,8 @@ Outside border Borde exterior Overtype Sobrescribir Overtype (typing replaces the characters after the caret) Sobrescribir (lo que escribes reemplaza los caracteres tras el cursor) + Show alignment guides when moving pictures and shapes Mostrar guías de alineación al mover imágenes y formas + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Los objetos que mueves se ajustan a los bordes y el centro de la página, a los márgenes y a otros objetos. Mantén pulsada Alt para moverlos libremente. Page display Visualización de página Paragraph marks Marcas de párrafo Personalize Personalizar diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index bde7bced7..a620b2742 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -516,6 +516,8 @@ Oval Ovaal Overtype Ülekirjutus Overtype (typing replaces the characters after the caret) Ülekirjutus (kirjutatu asendab kursori järel olevad märgid) + Show alignment guides when moving pictures and shapes Näita piltide ja kujundite teisaldamisel joondusjuhikuid + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Teisaldatavad objektid haakuvad lehe servade ja keskkoha, veeriste ja teiste objektide külge. Vabaks teisaldamiseks hoia all Alt-klahvi. PDF document (*.pdf) PDF-dokument (*.pdf) Page Leht Page Background Lehe taust diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dcf05de67..aa5feaeaa 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1888,6 +1888,8 @@ New word Nouveau mot No words yet. Add to Dictionary on a misspelled word puts it here. Aucun mot pour l’instant. Utilisez Ajouter au dictionnaire sur un mot mal orthographié pour l’ajouter ici. Overtype (typing replaces the characters after the caret) Refrappe (la frappe remplace les caractères situés après le curseur) + Show alignment guides when moving pictures and shapes Afficher les repères d'alignement lors du déplacement des images et des formes + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Les objets déplacés s'alignent sur les bords et le centre de la page, les marges et les autres objets. Maintenez Alt enfoncée pour les déplacer librement. Page display Affichage de la page Paragraph marks Marques de paragraphe Personalize Personnaliser diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index bf8a069d2..33fd08e8c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1574,6 +1574,8 @@ Outside border Külső szegély Overtype Felülírás Overtype (typing replaces the characters after the caret) Felülírás (a gépelés a kurzor utáni karaktereket cseréli) + Show alignment guides when moving pictures and shapes Igazítási segédvonalak megjelenítése képek és alakzatok mozgatásakor + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. A mozgatott objektumok a lap széleihez és közepéhez, a margókhoz és más objektumokhoz illeszkednek. Szabad mozgatáshoz tartsa lenyomva az Alt billentyűt. Page display Oldalmegjelenítés Paragraph marks Bekezdésjelek Personalize Személyre szabás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cb0605942..878d83292 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1515,6 +1515,8 @@ Mirror indents Rientri speculari Overtype Sovrascrittura Overtype (typing replaces the characters after the caret) Sovrascrittura (il testo digitato sostituisce i caratteri dopo il cursore) + Show alignment guides when moving pictures and shapes Mostra le guide di allineamento durante lo spostamento di immagini e forme + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Gli oggetti spostati si agganciano ai bordi e al centro della pagina, ai margini e agli altri oggetti. Tieni premuto Alt per spostarli liberamente. Page display Visualizzazione pagina Paragraph marks Segni di paragrafo Personalize Personalizza diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 01a055a69..988d2f8d4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -133,6 +133,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 設定 › 既定のアプリ を開きます。そこで .docx などの文書の種類ごとに WordCraft を選びます。 Overtype 上書き Overtype (typing replaces the characters after the caret) 上書き (入力した文字がカーソルの後ろの文字を置き換えます) + Show alignment guides when moving pictures and shapes 画像や図形を移動するときに配置ガイドを表示する + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. 移動したオブジェクトは、ページの端と中央、余白、ほかのオブジェクトに吸着します。Alt キーを押しながら移動すると自由に動かせます。 Page display ページの表示 Paragraph marks 段落記号 Personalize 個人設定 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 30f043461..13382375b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1910,6 +1910,8 @@ Outside: Na zewnątrz: Overtype Zastępowanie Overtype (typing replaces the characters after the caret) Zastępowanie (wpisywany tekst zastępuje znaki za kursorem) + Show alignment guides when moving pictures and shapes Pokazuj linie wyrównania podczas przesuwania obrazów i kształtów + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Przesuwane obiekty przyciągają się do krawędzi i środka strony, marginesów i innych obiektów. Przytrzymaj Alt, aby przesuwać swobodnie. Page display Wyświetlanie strony Pages like 1-3, 5 Strony, np. 1-3, 5 Paragraph Character Znak akapitu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8ff415f17..a9ff8489e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -127,6 +127,8 @@ Outside border Borda externa Overtype Sobrescrever Overtype (typing replaces the characters after the caret) Sobrescrever (o que você digita substitui os caracteres após o cursor) + Show alignment guides when moving pictures and shapes Mostrar guias de alinhamento ao mover imagens e formas + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Os objetos movidos se encaixam nas bordas e no centro da página, nas margens e em outros objetos. Segure Alt para mover livremente. Page display Exibição da página Paragraph marks Marcas de parágrafo Personalize Personalizar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b5456c933..3a26965d5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2423,6 +2423,8 @@ preview Title Пример No words yet. Add to Dictionary on a misspelled word puts it here. Слов пока нет. Команда «Добавить в словарь» для слова с ошибкой помещает его сюда. Overtype Замена Overtype (typing replaces the characters after the caret) Замена (вводимый текст заменяет символы после курсора) + Show alignment guides when moving pictures and shapes Показывать направляющие выравнивания при перемещении рисунков и фигур + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Перемещаемые объекты притягиваются к краям и центру страницы, к полям и к другим объектам. Удерживайте Alt, чтобы перемещать свободно. Page display Отображение страницы Paragraph marks Знаки абзаца Personalize Персонализация diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 3d8df0657..82ebf59af 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -156,6 +156,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otvara Podešavanja › Podrazumevane aplikacije, gde birate WordCraft za .docx i druge vrste dokumenata. Overtype Prepisivanje Overtype (typing replaces the characters after the caret) Prepisivanje (otkucano zamenjuje znakove posle kursora) + Show alignment guides when moving pictures and shapes Prikazuj vodilje za poravnanje pri pomeranju slika i oblika + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Pomereni objekti se prianjaju uz ivice i sredinu stranice, margine i druge objekte. Držite Alt da biste pomerali slobodno. Page display Prikaz strane Paragraph marks Oznake pasusa Personalize Personalizacija diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 592baa1b2..3f3d3ab2a 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -160,6 +160,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Отвара Подешавања › Подразумеване апликације, где бирате WordCraft за .docx и друге врсте докумената. Overtype Преписивање Overtype (typing replaces the characters after the caret) Преписивање (откуцано замењује знакове после курсора) + Show alignment guides when moving pictures and shapes Приказуј водиље за поравнање при померању слика и облика + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Померени објекти се приањају уз ивице и средину странице, маргине и друге објекте. Држите Alt да бисте померали слободно. Page display Приказ стране Paragraph marks Ознаке пасуса Personalize Персонализација diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c80fe40f2..213da13ad 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2422,6 +2422,8 @@ preview Title Örnek No words yet. Add to Dictionary on a misspelled word puts it here. Henüz sözcük yok. Yanlış yazılmış bir sözcükte «Sözlüğe ekle» onu buraya koyar. Overtype Üzerine yaz Overtype (typing replaces the characters after the caret) Üzerine yazma (yazılan metin imleçten sonraki karakterlerin yerine geçer) + Show alignment guides when moving pictures and shapes Resimleri ve şekilleri taşırken hizalama kılavuzlarını göster + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Taşınan nesneler sayfanın kenarlarına ve ortasına, kenar boşluklarına ve diğer nesnelere tutunur. Serbestçe taşımak için Alt tuşunu basılı tutun. Page display Sayfa görünümü Paragraph marks Paragraf işaretleri Personalize Kişiselleştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4bfb71e4d..ee532c3a7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -129,6 +129,8 @@ Outside border Зовнішня межа Overtype Заміна Overtype (typing replaces the characters after the caret) Заміна (введений текст замінює символи після курсора) + Show alignment guides when moving pictures and shapes Показувати напрямні вирівнювання під час переміщення зображень і фігур + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Переміщувані об'єкти прилипають до країв і центру сторінки, полів та інших об'єктів. Утримуйте Alt, щоб переміщувати вільно. Page display Відображення сторінки Paragraph marks Знаки абзацу Personalize Персоналізація diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 45fcb708e..3afcf4079 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2528,3 +2528,7 @@ # Web Save before New/Open (#1510) Saving {name} was cancelled, so it is still open and nothing was lost. Đã hủy lưu {name}, nên tài liệu vẫn mở và không mất gì. Waiting for the save to finish… Đang chờ lưu xong… + +# Alignment guides (#1519) + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Đối tượng được di chuyển sẽ bắt vào mép và tâm trang, lề và các đối tượng khác. Giữ Alt để di chuyển tự do. + Show alignment guides when moving pictures and shapes Hiện đường căn chỉnh khi di chuyển ảnh và hình diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6566f76b1..a9a5443f2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -145,6 +145,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 打开“设置 › 默认应用”,在其中为 .docx 及其他文档类型选择 WordCraft。 Overtype 改写 Overtype (typing replaces the characters after the caret) 改写(键入的内容替换插入点后的字符) + Show alignment guides when moving pictures and shapes 移动图片和形状时显示对齐参考线 + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. 移动的对象会吸附到页面的边缘和中心、页边距以及其他对象。按住 Alt 可自由移动。 Page display 页面显示 Paragraph marks 段落标记 Personalize 个性化 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 19091d6de..bfc522d85 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -134,6 +134,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 開啟「設定 › 預設應用程式」,在其中為 .docx 及其他文件類型選擇 WordCraft。 Overtype 取代 Overtype (typing replaces the characters after the caret) 取代(輸入的內容會取代插入點後的字元) + Show alignment guides when moving pictures and shapes 移動圖片和圖案時顯示對齊參考線 + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. 移動的物件會貼齊頁面的邊緣和中心、邊界以及其他物件。按住 Alt 可自由移動。 Page display 頁面顯示 Paragraph marks 段落標記 Personalize 個人化 diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 235de3483..6eae7283f 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -673,6 +673,9 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) { // On macOS Option+key types accents, so Alt must never arm keytips there. const ALT_ARMS: bool = !cfg!(target_os = "macos"); let events: Vec = ctx.input(|i| i.events.clone()); + // Alt pressed or let go while dragging (an object, a selection) is part of the drag (Alt + // moves an object freely), not a keytip tap. + let dragging = app.canvas.obj_drag.is_some() || ctx.input(|i| i.pointer.any_down()); let mut other_key = false; let mut alt_released = false; let mut f10 = false; @@ -698,6 +701,9 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) { } _ => {} } + if dragging && app.ui.alt_pressed { + app.ui.alt_chord_used = true; + } } // Route letter/navigation keys to the keytips while they are showing. let mut consumed = Vec::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1a9fd0a72..c945f17b9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -53,6 +53,7 @@ pub mod equation_tab; pub mod file_browser; pub mod file_dialogs; pub mod frame; +pub mod guides; pub mod i18n; pub mod icons; pub mod ink; @@ -303,6 +304,9 @@ pub struct UiState { pub recent_count: usize, /// File › Options › General: show the mini toolbar over a selection. pub mini_toolbar: bool, + /// File › Options › Advanced › Editing: moving a picture or shape snaps it to alignment + /// guides (on by default; Alt moves freely). + pub alignment_guides: bool, /// File › Options › General: start on File › Home (Blank document, templates, Recent) when /// the app opens without a document (#766); on, as in Word. See [`WordApp::start_screen`]. pub start_screen: bool, @@ -358,6 +362,7 @@ impl Default for UiState { options_pane: "general".into(), recent_count: 12, mini_toolbar: true, + alignment_guides: true, start_screen: true, print_backgrounds: false, update_fields_before_print: false, @@ -1381,6 +1386,16 @@ impl WordApp { } json!({"caretAnimation": self.ui.caret_animation}) } + "ui.alignmentGuides" => { + // File › Options › Advanced › Editing: `value` true or false turns snapping to + // alignment guides while moving pictures and shapes on or off; no value reads it. + match p.get("value") { + None => {} + Some(Value::Bool(on)) => self.ui.alignment_guides = *on, + Some(_) => return Some(Err("`value` must be true or false".into())), + } + json!({"alignmentGuides": self.ui.alignment_guides}) + } "ui.startScreen" => { // File › Options › General › Start-up (#766): `value` true or false turns showing // File › Home at start-up on or off; no value reads it. @@ -2273,6 +2288,25 @@ mod tests { WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) } + /// File › Options › Advanced › Editing: alignment guides are a UI command, on by default and + /// kept between runs. + #[test] + fn alignment_guides_are_a_ui_command_and_survive_a_restart() { + let mut a = app(); + assert_eq!(a.run("ui.alignmentGuides", json!({})).unwrap(), json!({"alignmentGuides": true}), "on by default"); + assert_eq!(a.run("ui.alignmentGuides", json!({"value": false})).unwrap(), json!({"alignmentGuides": false})); + for junk in [json!({"value": "no"}), json!({"value": 0}), json!({"value": null})] { + assert!(a.run("ui.alignmentGuides", junk).is_err()); + } + assert!(!a.ui.alignment_guides, "a rejected value keeps the setting"); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert!(!b.ui.alignment_guides); + let old: UiState = serde_json::from_str(r#"{"tab":"Insert"}"#).unwrap(); + assert!(old.alignment_guides, "older settings leave it on"); + } + /// File › Options › Advanced › Animation: the caret's animation is a UI command, kept between /// runs. #[test] diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index ee771afc9..aa6c9e840 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -8,18 +8,23 @@ //! drag starts (#1010: a cut of the page would carry along whatever is behind it). The drop is //! one command, one relayout. //! +//! Moving one snaps it to alignment guides ([`crate::guides`]): the page's edges and centre, the +//! margins and the other floating objects on the page, shown as thin lines while dragging. Alt +//! moves freely; File › Options › Advanced turns them off (`ui.alignmentGuides`). +//! //! A selected connector shows a handle at each end instead of a frame: dragging one onto a shape //! glues it to the nearest connection site there (`shape.connect`), elsewhere leaves it free. use egui::{Color32, Painter, Pos2, Rect, Ui}; use serde_json::json; -use wordcraft_doc::para::{InlineObject, OBJ}; +use wordcraft_doc::para::{InlineObject, OBJ, Wrap}; use wordcraft_doc::{PartKind, Pos, StoryRef}; use wordcraft_layout::DocLayout; use wordcraft_layout::hit::ObjectHit; use crate::WordApp; use crate::frame::{self, Drag, Grab}; +use crate::guides; use crate::theme::Tokens; /// The band along a text box's border that grabs the box rather than its text, screen points. @@ -39,6 +44,8 @@ pub struct ObjectDrag { picture: bool, /// Dragging a connector's end (false: its start, true: its end) to this page point. end: Option<(bool, (f32, f32))>, + /// The alignment guides a move snapped to, shown while dragging ([`crate::guides`]). + guides: Vec, /// The object drawn alone over its rotated bounds, for the preview: made once it moves. preview: Option, } @@ -163,6 +170,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] } else { // Pictures keep their proportions from a corner unless Shift; shapes only with Shift. d.drag.update(at, pages, scale, d.min, d.picture != shift); + // Alignment guides: a move snaps to the page, the margins and the other objects + // (Alt: freely). + d.guides.clear(); + if d.drag.grab == Grab::Move && d.drag.moved && app.ui.alignment_guides && !ui.input(|i| i.modifiers.alt) { + snap_move(layout, &mut d, scale); + } } if d.drag.moved && d.preview.is_none() && shows_pixels(&d) { d.preview = preview(app, ui.ctx(), layout, &d.object, scale); @@ -185,7 +198,8 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] // A selected connector's end: drag it to glue it. if pressed && let Some((object, which)) = connector_end_at(app, layout, pages, scale, at) { let drag = Drag::new(Grab::Move, object.page, object.rect, 0.0, at); - app.canvas.obj_drag = Some(ObjectDrag { drag, object, min: 0.0, picture: false, end: Some((which, (0.0, 0.0))), preview: None }); + app.canvas.obj_drag = + Some(ObjectDrag { drag, object, min: 0.0, picture: false, end: Some((which, (0.0, 0.0))), guides: Vec::new(), preview: None }); return true; } let Some((object, grab)) = grab_at(app, layout, pages, scale, at) else { return false }; @@ -211,6 +225,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] min, picture, end: None, + guides: Vec::new(), preview: None, }); } @@ -218,6 +233,56 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] true } +/// Snap a moved object's bounds (rotated ones by their turned box) to the guides of the page +/// it's over: the page's edges and centre, its margins, the other floating objects there. +fn snap_move(layout: &DocLayout, d: &mut ObjectDrag, scale: f32) { + let Some(pg) = layout.pages.get(d.drag.to_page) else { return }; + let candidates = guides::candidates(paper_width(pg), pg.h, pg.body, snap_targets(pg, &d.object)); + let s = guides::snap(d.object.spin.bounds(d.drag.rect), &candidates, guides::SNAP_SCREEN / scale.max(0.01)); + d.drag.rect.x += s.dx; + d.drag.rect.y += s.dy; + d.guides = s.lines; +} + +/// The page's own width, without the comment balloons' markup area. +fn paper_width(pg: &wordcraft_layout::Page) -> f32 { + (pg.w - pg.markup).max(0.0) +} + +/// The bounds of the floating objects on `pg` that `me` can snap to: all but itself and, for a +/// text box, the objects in its text (they move with it). +fn snap_targets(pg: &wordcraft_layout::Page, me: &ObjectHit) -> Vec { + let pos = me.pos(); + let own = me.text_box.map(StoryRef::Part); + pg.items + .iter() + .chain(&pg.header) + .chain(&pg.footer) + .filter_map(|it| match it { + wordcraft_layout::Placed::Object { rect, spin, story, path, off, wrap, .. } + if *wrap != Wrap::Inline && !(*story == pos.story && *path == pos.path && *off == pos.off) && Some(*story) != own => + { + Some(spin.bounds(*rect)) + } + _ => None, + }) + .collect() +} + +/// The guides a move snapped to: thin lines across the page (not into the balloon area). +fn paint_guides(d: &ObjectDrag, painter: &Painter, t: &Tokens, layout: &DocLayout, pages: &[Rect], scale: f32) { + let Some(p) = pages.get(d.drag.to_page) else { return }; + let right = layout.pages.get(d.drag.to_page).map_or(p.max.x, |pg| (p.min.x + paper_width(pg) * scale).min(p.max.x)); + let stroke = egui::Stroke::new(1.0, t.guide); + for g in &d.guides { + let line = match g.axis { + guides::Axis::X => [egui::pos2(p.min.x + g.at * scale, p.min.y), egui::pos2(p.min.x + g.at * scale, p.max.y)], + guides::Axis::Y => [egui::pos2(p.min.x, p.min.y + g.at * scale), egui::pos2(right, p.min.y + g.at * scale)], + }; + painter.line_segment(line, stroke); + } +} + /// Create Link's click: link the box it started from to the text box under `at` (anywhere on it), /// if `pressed`; either way, linking is over. fn link_click(app: &mut WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2, pressed: bool) { @@ -358,6 +423,7 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p painter.image(tex.id(), to, uv, Color32::from_white_alpha(PREVIEW_OPACITY)); } frame::paint(painter, t, to, d.drag.deg, false, false, false); + paint_guides(d, painter, t, layout, pages, scale); return; } // The others selected along with it (Shift+click): their outlines. @@ -496,6 +562,165 @@ mod tests { let (app, hit) = app_with(shape); assert!(!fixed(&app, &hit)); } + /// Alignment guides: dragging a floating shape to within a few points of the page's centre + /// snaps its centre exactly onto it and shows the guide while dragging; the drop lands there, + /// and the guides go. Holding Alt drags freely. + #[test] + fn dragging_a_shape_snaps_to_the_page_centre_unless_alt() { + use egui::{Event, Modifiers, PointerButton}; + let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default()); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap(); + a.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let page = h.state_mut().session.layout().pages.first().map(|p| p.w - p.markup).unwrap(); + let centre = page / 2.0; + let shape = |h: &mut egui_kittest::Harness<'_, WordApp>| h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap().rect; + let button = + |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + // Press on the shape's centre and drag it so its centre ends 1.5 pt right of the page's. + let drag = |h: &mut egui_kittest::Harness<'_, WordApp>, alt: bool| { + let r = shape(h); + let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0); + let from = crate::canvas::page_to_screen(h.state_mut(), 0, cx, cy).unwrap(); + let to = crate::canvas::page_to_screen(h.state_mut(), 0, centre + 1.5, cy).unwrap(); + h.event(Event::ModifiersChanged(if alt { Modifiers::ALT } else { Modifiers::default() })); + h.event(Event::PointerMoved(from)); + h.event(button(from, true)); + h.step(); + // Out past the drag threshold, then to the target. + for at in [from + egui::vec2(0.0, 40.0), to + egui::vec2(0.0, 40.0), to] { + h.event(Event::PointerMoved(at)); + h.step(); + } + let d = h.state().canvas.obj_drag.as_ref().expect("dragging"); + let mid = (d.drag.rect, d.guides.clone()); + h.event(button(to, false)); + for _ in 0..3 { + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::default())); + h.step(); + mid + }; + let (rect, guides) = drag(&mut h, false); + assert!((rect.x + rect.w / 2.0 - centre).abs() < 1e-3, "snapped onto the page's centre while dragging: {rect:?}"); + assert!(guides.contains(&crate::guides::Guide::x(centre)), "the centre guide shows: {guides:?}"); + assert!(h.state().canvas.obj_drag.is_none(), "dropped: no guides any more"); + let r = shape(&mut h); + assert!((r.x + r.w / 2.0 - centre).abs() < 0.01, "dropped on the centre: {r:?}"); + // Alt: no snapping, no guides; it lands where the pointer put it. + let (rect, guides) = drag(&mut h, true); + assert!((rect.x + rect.w / 2.0 - centre - 1.5).abs() < 0.01, "free with Alt: {rect:?}"); + assert!(guides.is_empty(), "{guides:?}"); + let r = shape(&mut h); + assert!((r.x + r.w / 2.0 - centre - 1.5).abs() < 0.01, "{r:?}"); + } + /// A floating rectangle shape, `w` × `h`. + fn floating_rect(w: f32, h: f32) -> InlineObject { + InlineObject::Shape { + kind: ShapeKind::Rectangle, + w, + h, + fill: None, + stroke: None, + stroke_width: 1.0, + float: wordcraft_doc::para::Float { wrap: Wrap::InFrontOfText, x: 10.0, y: 10.0, ..Default::default() }, + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + } + } + + /// A text box doesn't snap to the objects inside it (they move with it). + #[test] + fn a_text_box_does_not_snap_to_its_own_contents() { + let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default()); + app.run("insert.textBox", json!({"text": "Box"})).unwrap(); + let bx = app.session.layout().find_object(0, |o| o.text_box.is_some()).expect("the box"); + let end = Pos { off: bx.off + OBJ.len_utf8(), ..bx.pos() }; + app.run("select.range", json!({"anchor": bx.pos(), "focus": end})).unwrap(); + app.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 200, "width": 200, "height": 150})).unwrap(); + let id = app.session.layout().find_object(0, |o| o.text_box.is_some()).and_then(|o| o.text_box).unwrap(); + let inner = Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 0 }; + app.session.doc.insert_object(&inner, floating_rect(30.0, 20.0), &Default::default()).unwrap(); + app.session.touch(); + let layout = app.session.layout(); + let bx = layout.find_object(0, |o| o.text_box.is_some()).unwrap(); + let pg = layout.pages.first().unwrap(); + let inside = layout.find_object(0, |o| o.story == StoryRef::Part(id)).expect("the box's own object is laid out"); + let targets = snap_targets(pg, &bx); + assert!(!targets.contains(&inside.bounds()), "{targets:?}"); + // Another floating object on the page is still a target. + let other = snap_targets(pg, &inside); + assert!(other.contains(&bx.bounds()), "{other:?}"); + } + + /// Pressing Alt in the middle of a drag stops the snapping, and letting it go again doesn't + /// turn keytips on (the Alt was part of the drag, not a tap). + #[test] + fn alt_pressed_during_a_drag_unsnaps_and_keeps_keytips_off() { + use egui::{Event, Modifiers, PointerButton}; + let mut a = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default()); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("insert.shape", json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap(); + a.run("arrange.bounds", json!({"page": 0, "x": 100, "y": 250})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let centre = h.state_mut().session.layout().pages.first().map(|p| (p.w - p.markup) / 2.0).unwrap(); + let r = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).unwrap().rect; + let cy = r.y + r.h / 2.0; + let from = crate::canvas::page_to_screen(h.state_mut(), 0, r.x + r.w / 2.0, cy).unwrap(); + let to = crate::canvas::page_to_screen(h.state_mut(), 0, centre + 1.5, cy).unwrap(); + let button = + |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + h.event(Event::PointerMoved(from)); + h.event(button(from, true)); + h.step(); + for at in [from + egui::vec2(0.0, 40.0), to] { + h.event(Event::PointerMoved(at)); + h.step(); + } + assert!(!h.state().canvas.obj_drag.as_ref().expect("dragging").guides.is_empty(), "snapped before Alt"); + // Alt down mid-drag, as a key and a modifier change (backends send both). + let alt_key = + |pressed: bool, modifiers: Modifiers| Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed, repeat: false, modifiers }; + h.event(alt_key(true, Modifiers::ALT)); + h.event(Event::ModifiersChanged(Modifiers::ALT)); + h.event(Event::PointerMoved(to + egui::vec2(0.0, 1.0))); + h.step(); + let d = h.state().canvas.obj_drag.as_ref().expect("still dragging"); + assert!(d.guides.is_empty() && (d.drag.rect.x + d.drag.rect.w / 2.0 - centre - 1.5).abs() < 0.01, "free with Alt"); + // Alt up, then the release. + h.event(alt_key(false, Modifiers::default())); + h.event(Event::ModifiersChanged(Modifiers::default())); + h.event(button(to, false)); + for _ in 0..3 { + h.step(); + } + assert!(h.state().canvas.obj_drag.is_none()); + assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off, "Alt during a drag isn't a keytip tap"); + } /// #1010: dragging a shape off text, the preview is the shape alone, not the text that was /// behind it. An unfilled red rectangle in front of black text is dragged down into the empty diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index da75c87a4..5f97efc29 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -370,6 +370,14 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { flag(app, ui, "Typing replaces selected text", "typingReplacesSelection", pr.typing_replaces_selection); flag(app, ui, "Use the Insert key to switch overtype on and off", "insertKeyOvertype", pr.insert_key_overtype); flag(app, ui, "Overtype (typing replaces the characters after the caret)", "overtype", pr.overtype); + let mut guides = app.ui.alignment_guides; + if ui + .checkbox(&mut guides, tl!("Show alignment guides when moving pictures and shapes")) + .on_hover_text(tl!("Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely.")) + .changed() + { + let _ = app.run("ui.alignmentGuides", json!({"value": guides})); + } ui.add_space(8.0); section(ui, "Display"); egui::Grid::new("options_display").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { @@ -814,6 +822,30 @@ mod tests { assert!(h.state().ui.start_screen, "and on again"); } + /// File › Options › Advanced › Editing: the alignment guides checkbox is the + /// `ui.alignmentGuides` setting. + #[test] + fn advanced_alignment_guides_checkbox_turns_snapping_off_and_on() { + let mut a = app(); + a.run("file.options", json!({"pane": "advanced"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + let label = "Show alignment guides when moving pictures and shapes"; + h.get_by_label(label).click(); + h.run_steps(4); + assert!(!h.state().ui.alignment_guides, "the checkbox turns them off"); + h.get_by_label(label).click(); + h.run_steps(4); + assert!(h.state().ui.alignment_guides, "and on again"); + } + /// File › Options › Advanced › Animation: the checkbox is the `ui.caretAnimation` setting, /// and the section is translated. #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 9c8ff4aab..7f77b926c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -265,6 +265,9 @@ pub struct Tokens { /// Selection-frame handles (pictures, shapes, text boxes) and their shadow. pub handle: Color32, pub handle_shadow: Color32, + /// Alignment guides while moving a picture or shape: drawn on the page, so legible on white + /// paper in both themes. + pub guide: Color32, pub menu: Color32, pub blue: Color32, pub red: Color32, @@ -305,6 +308,7 @@ impl Tokens { page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22), handle: Color32::WHITE, handle_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x28), + guide: Color32::from_rgb(0xC0, 0x28, 0x8C), menu: Color32::WHITE, blue: Color32::from_rgb(0x2F, 0x6F, 0xD0), red: Color32::from_rgb(0xD1, 0x3B, 0x3B), @@ -346,6 +350,7 @@ impl Tokens { page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60), handle: Color32::from_rgb(0xF2, 0xF2, 0xF2), handle_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x5A), + guide: Color32::from_rgb(0xE0, 0x4C, 0xB0), menu: Color32::from_rgb(0x2B, 0x2B, 0x2B), blue: Color32::from_rgb(0x6C, 0x9B, 0xEA), red: Color32::from_rgb(0xEE, 0x6A, 0x6A), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4ca1efba5..da84d8c82 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely)…) | | `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 64a2dd607..97a56f7cc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,12 +1,12 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: Inspect Document hidden text in every story, #1344; previously trivial: Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: alignment guides while dragging pictures and shapes; previously trivial: Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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 own (`AGENTS.md`: no Microsoft iconography, ever). -**Dimension: ~65% (estimated), 61–94 h to full** (the sum of the rows below; dialog depth is counted here, not under features). +**Dimension: ~65% (estimated), 59–92 h to full** (the sum of the rows below; dialog depth is counted here, not under features). | Area | WordCraft | Word | Parity | Hours | |---|---|---|---|---| @@ -25,7 +25,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 85% | 1–2 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — | | Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it; selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows. Missing: a single selected cell's border | ✅ | 95% | 1 | -| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles and guides missing | ✅ | 25% | 3–5 | +| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Crop handles missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 50% | 2–3 | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 | @@ -55,6 +55,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | | 2026-10-14 | trivial | Save Changes prompt on the web: Save before New/Open holds the command back until the browser's save picker has written and closed the file; a cancelled or failed picker save keeps the document and brings the prompt back with the reason (#1315) | | 2026-10-14 | trivial | Inspect Document (#1344): hidden text found by effective formatting (direct, character style, paragraph style) in the body, headers, footers, notes, comments and text boxes; Remove All deletes it all in one undo step (whole hidden paragraphs with their marks, and the notes of hidden references) and reports a `residual` list, including tables whose style hides text | +| 2026-10-14 | trivial | Alignment guides while moving pictures, shapes and text boxes: WordCraft's own design (snapping to the page, margins and other objects, thin lines while dragging, Alt moves freely, `ui.alignmentGuides`); 25% → 50% | | 2026-10-14 | trivial | Page Setup: a Paper group with Paper size (the named sizes, the one the section has in either orientation, or Custom size) and Width and Height in the measurement unit; a wider-than-tall size turns the orientation to landscape; a size the margins don't fit keeps the dialog open with a message and applies nothing. Layout › Size ends with More Paper Sizes…, which opens it (#1433) | | 2026-10-13 | trivial | Dark page (View › Switch Modes): highlights and character shading are dimmed toward black, keeping their hue (yellow turns a dim olive), instead of having their lightness inverted, which left yellow bright under white text; text on them draws light (white for black and Automatic), coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule. Light page, print and exports unchanged. Unlike Word, Automatic text stays white on the dark page (#1252) | | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | From 666b9afc5a0e362963ba7ce6d7600cc6968fc955 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:28:59 +0200 Subject: [PATCH 085/199] Draw: Ink to Shape recognises arrows (#1520) * feat-ink-to-shape-arrows: WIP * Draw: Ink to Shape recognises arrows (gap 12) Co-Authored-By: Claude Opus 5.5 * Ink to Shape arrows: hand-drawn shafts, more tests, docs Co-Authored-By: Claude Opus 5.5 * Ink to Shape arrows: the tip is the first near-farthest point Co-Authored-By: Claude Opus 5.5 * Ink to Shape arrows: step to the real tip on dense strokes Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/draw.rs | 50 +++- crates/engine/src/cmd/ink_shape.rs | 368 ++++++++++++++++++++++++++++- docs/gaps.md | 5 +- docs/hardware-parity.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 5 +- 7 files changed, 427 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 861470e63..df85ead13 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: alignment guides while dragging pictures and shapes; 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: 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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: Ink to Shape arrows; 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. @@ -181,6 +181,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 | | 2026-10-12 | On the web, pasting a picture while a comment, the Find box, a dialog field or the Backstage has the keyboard no longer inserts it into the document; it inserts once only when the page is the edit target (#1349) | | 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing | +| 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -358,6 +359,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) | | 2026-10-12 | trivial | Progress log: web picture paste respects the edit target (#1349) | | 2026-10-14 | trivial | Progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329) | +| 2026-10-14 | trivial | Progress log: Ink to Shape recognises single-stroke arrows | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 8ba38e5c9..f445228f9 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -7,7 +7,7 @@ //! //! Draw › Lasso Select picks the strokes (and drawings) inside a loop drawn on a page; the //! selection can then be moved, recoloured, deleted or turned into shapes. Ink to Shape replaces -//! strokes that look like a line, rectangle, ellipse, triangle or polygon with that shape +//! strokes that look like a line, arrow, rectangle, ellipse, triangle or polygon with that shape //! ([`ink_shape`](super::ink_shape)). Ink Replay plays the strokes back in the order they were //! drawn (each stroke keeps its place in that order). Add Pen keeps custom pens in a gallery. @@ -697,7 +697,7 @@ fn convert_selection(s: &mut Session, _: &Value) -> CmdResult { /// 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, .. }) = para.object_at_mut(pos.off) else { return Ok(None) }; + let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, extra, .. }) = para.object_at_mut(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); }; @@ -718,12 +718,20 @@ fn convert_at(s: &mut Session, pos: &Pos) -> Result, CmdErr InkShape::Ellipse { .. } => (ShapeKind::Ellipse, None), InkShape::Line { a, b } => (ShapeKind::Freeform, outline(local(&[*a, *b]), false)), InkShape::Polygon(c) => (ShapeKind::Freeform, outline(local(c), true)), + // A straight connector runs from its frame's top-left to bottom-right; flips turn it. + InkShape::Arrow { a, b, head_at_end, head_at_start } => { + float.flip_h = b[0] < a[0]; + float.flip_v = b[1] < a[1]; + extra.arrow_end = *head_at_end; + extra.arrow_start = *head_at_start; + (ShapeKind::StraightConnector, None) + } }; *kind = new_kind; *freeform = geometry; *fill = None; - *w = bw; - *h = bh; + let [_, _, raw_w, raw_h] = found.bounds(); + (*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) }; float.x += bx; float.y += by; para.touch(); @@ -975,6 +983,40 @@ mod tests { assert!(!s.view.draw.ink_to_shape, "converting the selection doesn't turn the mode on"); } + #[test] + fn ink_to_shape_turns_an_arrow_stroke_into_a_line_shape_with_a_head_that_survives_docx() { + let mut s = session(); + s.run("draw.inkToShape", &json!({"value": true})).unwrap(); + // Right to left and downwards: shaft, then both barbs of the head at the tip (100, 300). + let tail = [300.0f32, 200.0]; + let tip = [100.0f32, 300.0]; + let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]); + let barb = |sign: f32| [tip[0] + 30.0 * (back + sign * 0.5).cos(), tip[1] + 30.0 * (back + sign * 0.5).sin()]; + let mut pts: Vec<[f32; 2]> = + (0..20).map(|i| i as f32 / 20.0).map(|t| [tail[0] + (tip[0] - tail[0]) * t, tail[1] + (tip[1] - tail[1]) * t]).collect(); + pts.extend([tip, barb(1.0), tip, barb(-1.0)]); + let r = s.run("draw.stroke", &json!({"page": 0, "points": pts, "color": "C00000", "width": 2})).unwrap(); + assert_eq!(r["shape"], "arrow"); + let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); + let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap(); + let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, extra, float, .. } = obj else { panic!("{obj:?}") }; + assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true)); + assert!(extra.arrow_end && !extra.arrow_start); + // Tail at the top right, tip at the bottom left: the frame is flipped across. + assert!(float.flip_h && !float.flip_v, "{float:?}"); + assert!((w - 200.0).abs() < 1.0 && (h - 100.0).abs() < 1.0, "{w} x {h}"); + // It survives a DOCX save and open. + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + let back = wordcraft_docx::read(&bytes).unwrap(); + let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect(); + let Some(InlineObject::Shape { extra, float, .. }) = + again.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })) + else { + panic!("{again:?}") + }; + assert!(extra.arrow_end && !extra.arrow_start && float.flip_h, "{extra:?} {float:?}"); + } + #[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 b7b3aa3a6..c65b37c93 100644 --- a/crates/engine/src/cmd/ink_shape.rs +++ b/crates/engine/src/cmd/ink_shape.rs @@ -4,7 +4,8 @@ //! 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. Anything else (scribbles, curves, letters) stays ink. +//! 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. use wordcraft_doc::freeform::MAX_POINTS; @@ -13,6 +14,9 @@ use wordcraft_doc::freeform::MAX_POINTS; pub enum InkShape { /// A straight line from `a` to `b`. Line { a: [f32; 2], b: [f32; 2] }, + /// A straight arrow from the tail `a` to the tip `b`; the heads are the `head_at_*` flags + /// (recognised strokes have one, at `b`). + Arrow { a: [f32; 2], b: [f32; 2], head_at_end: bool, head_at_start: bool }, /// An upright rectangle: top-left and size (equal sides when drawn nearly square). Rectangle { x: f32, y: f32, w: f32, h: f32 }, /// An upright ellipse in this box (a circle when drawn nearly round). @@ -26,6 +30,7 @@ impl InkShape { pub fn name(&self) -> &'static str { match self { InkShape::Line { .. } => "line", + InkShape::Arrow { .. } => "arrow", InkShape::Rectangle { w, h, .. } if w == h => "square", InkShape::Rectangle { .. } => "rectangle", InkShape::Ellipse { w, h, .. } if w == h => "circle", @@ -39,7 +44,7 @@ impl InkShape { pub fn bounds(&self) -> [f32; 4] { match self { InkShape::Rectangle { x, y, w, h } | InkShape::Ellipse { x, y, w, h } => [*x, *y, *w, *h], - InkShape::Line { a, b } => bbox(&[*a, *b]), + InkShape::Line { a, b } | InkShape::Arrow { a, b, .. } => bbox(&[*a, *b]), InkShape::Polygon(c) => bbox(c), } } @@ -78,6 +83,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::>())) { + return Some(arrow); + } // Other shapes are closed: the stroke ends near where it began and goes around. if gap > 0.2 * size || length < 2.0 * size { return None; @@ -106,6 +115,79 @@ pub fn recognise(pts: &[[f32; 2]]) -> Option { }) } +/// Barb length limits, as a fraction of the shaft, and the angle each barb makes with the shaft +/// (degrees). +const BARB_LEN: (f32, f32) = (0.05, 0.5); +const BARB_ANGLE: (f32, f32) = (15.0, 75.0); + +/// An arrow drawn shaft first: the shaft runs from the start to the tip (the point farthest from +/// the start) and stays near its chord like a line does; then the pen draws one barb, comes back +/// to the tip and draws the other. The barbs must leave the tip on opposite sides of the shaft at +/// 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; + } + // 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); + let (mut barbs, mut out, mut gap): (Vec<(f32, [f32; 2])>, bool, f32) = (Vec::new(), false, f32::INFINITY); + for q in head { + let d = dist(tip, *q); + if d > 0.25 * reach { + if !out { + if barbs.len() == 2 { + return None; + } + barbs.push((d, *q)); + out = true; + } else if let Some(b) = barbs.last_mut().filter(|b| d > b.0) { + *b = (d, *q); + } + } else { + out = false; + if barbs.len() == 1 { + gap = gap.min(d); + } + } + } + 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) { + return None; + } + // Each barb as a vector from the tip, 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 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 }) +} + /// Left, top, width and height of `pts` (zero for none). fn bbox(pts: &[[f32; 2]]) -> [f32; 4] { let Some(f) = pts.first() else { return [0.0; 4] }; @@ -322,4 +404,286 @@ mod tests { assert_eq!(recognise(&junk), None); assert_eq!(recognise(&[]), None); } + + /// A stroke drawn as a shaft from `tail` to `tip`, then both barbs of a head (back to the + /// tip between them). + fn arrow_stroke(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> Vec<[f32; 2]> { + arrow_stroke_n(tail, tip, barb, spread_deg, 24, 5) + } + + /// Points from `a` towards `b` (not including `b`), `n` of them. + fn line(a: [f32; 2], b: [f32; 2], n: usize) -> Vec<[f32; 2]> { + (0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect() + } + + /// An arrow sampled as a pen samples it: about one point per point of travel. + fn dense_arrow(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> Vec<[f32; 2]> { + arrow_stroke_n(tail, tip, barb, spread_deg, dist(tail, tip).round() as usize, (barb.round() as usize).max(2)) + } + + /// `arrow_stroke` with `n` shaft points and `m` points per barb pass. + fn arrow_stroke_n(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32, n: usize, m: usize) -> Vec<[f32; 2]> { + let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]); + let barb_end = |sign: f32| { + let a = back + sign * spread_deg.to_radians(); + [tip[0] + barb * a.cos(), tip[1] + barb * a.sin()] + }; + let mut pts = line(tail, tip, n); + pts.extend(line(tip, barb_end(1.0), m)); + pts.extend(line(barb_end(1.0), tip, m)); + pts.extend(line(tip, barb_end(-1.0), m)); + pts.push(barb_end(-1.0)); + pts + } + + #[test] + fn arrows_drawn_in_one_stroke_are_recognised_in_any_direction() { + for (tail, tip) in [ + ([20.0, 100.0], [180.0, 100.0]), + ([180.0, 100.0], [20.0, 100.0]), + ([100.0, 20.0], [100.0, 180.0]), + ([100.0, 180.0], [100.0, 20.0]), + ([30.0, 30.0], [150.0, 120.0]), + ([150.0, 120.0], [30.0, 30.0]), + ] { + let stroke = arrow_stroke(tail, tip, 25.0, 30.0); + 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) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?} for {tail:?} {tip:?}"); + assert_eq!(recognise(&stroke).map(|s| s.name()), Some("arrow")); + } + // Head first, then the shaft: the same arrow. + let mut reversed = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0); + reversed.reverse(); + let Some(InkShape::Arrow { a, b, .. }) = recognise(&reversed) else { panic!("{:?}", recognise(&reversed)) }; + assert!(dist(a, [20.0, 100.0]) < 3.0 && dist(b, [180.0, 100.0]) < 3.0, "{a:?} {b:?}"); + } + + #[test] + fn lines_vs_barbs_hooks_and_scribbles_are_not_arrows() { + let stroke = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0); + // A plain line stays a line. + let line: Vec<[f32; 2]> = (0..=30).map(|i| [20.0 + i as f32 * 5.0, 100.0]).collect(); + assert_eq!(recognise(&line).map(|s| s.name()), Some("line")); + // A "V" alone is not an arrow (nor anything). + let v = [[100.0, 100.0], [75.0, 87.5], [100.0, 100.0], [75.0, 112.5]]; + assert_eq!(recognise(&v), None); + assert_eq!(recognise(&[[75.0, 87.5], [100.0, 100.0], [75.0, 112.5]]), None); + // A line with one barb (a hook) is not an arrow. + let hook = &stroke[..34]; + assert!(!matches!(recognise(hook), Some(InkShape::Arrow { .. })), "{:?}", recognise(hook)); + // Barbs outside 15..75 degrees, or too long or too short, are not heads. + for (barb, spread) in [(25.0, 5.0), (25.0, 88.0), (100.0, 30.0), (1.0, 30.0)] { + let s = arrow_stroke([20.0, 100.0], [180.0, 100.0], barb, spread); + assert!(!matches!(recognise(&s), Some(InkShape::Arrow { .. })), "barb {barb} spread {spread}"); + } + // Both barbs on the same side are not a head. + let lopsided = [[20.0, 100.0], [180.0, 100.0], [155.0, 87.0], [180.0, 100.0], [158.0, 80.0]]; + assert!(!matches!(recognise(&lopsided), Some(InkShape::Arrow { .. }))); + // A zigzag scribble stays ink. + let zig: Vec<[f32; 2]> = (0..9).map(|i| [i as f32 * 20.0, if i % 2 == 0 { 0.0 } else { 40.0 }]).collect(); + assert_eq!(recognise(&zig), None); + } + + #[test] + fn hostile_arrow_points_never_panic() { + let mut junk = arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0); + junk.insert(3, [f32::NAN, 5.0]); + junk.push([f32::INFINITY, 1.0]); + assert!(matches!(recognise(&junk), Some(InkShape::Arrow { .. }))); + let huge: Vec<[f32; 2]> = (0..MAX_POINTS * 2).map(|i| [(i % 977) as f32, ((i * 31) % 541) as f32]).collect(); + let _ = recognise(&huge); + let _ = recognise(&[[1e5, 1e5], [-1e5, 1e5], [1e5, -1e5], [-1e5, -1e5], [0.0, 0.0]]); + let _ = recognise(&[[0.0, 0.0]; 7]); + } + + /// An arrow as a hand draws it: the shaft sags by `bow` and wobbles, the tip may be rounded, + /// and each barb has its own length and angle (`(length, degrees)`, left then right). + fn hand_arrow(tail: [f32; 2], tip: [f32; 2], bow: f32, round_tip: bool, barbs: [(f32, f32); 2]) -> Vec<[f32; 2]> { + hand_arrow_n(tail, tip, bow, round_tip, barbs, 30, 6) + } + + /// `hand_arrow` with `n` shaft points and `m` points per barb pass. + fn hand_arrow_n(tail: [f32; 2], tip: [f32; 2], bow: f32, round_tip: bool, barbs: [(f32, f32); 2], n: usize, m: usize) -> Vec<[f32; 2]> { + let len = dist(tail, tip); + let dir = [(tip[0] - tail[0]) / len, (tip[1] - tail[1]) / len]; + let normal = [-dir[1], dir[0]]; + let mut pts: Vec<[f32; 2]> = (0..n) + .map(|i| { + let t = i as f32 / n as f32; + let off = bow * 4.0 * t * (1.0 - t) + (i as f32 * 1.7).sin() * 0.6; + [tail[0] + (tip[0] - tail[0]) * t + normal[0] * off, tail[1] + (tip[1] - tail[1]) * t + normal[1] * off] + }) + .collect(); + let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]); + let end = |(l, deg): (f32, f32), sign: f32| { + let a = back + sign * deg.to_radians(); + [tip[0] + l * a.cos(), tip[1] + l * a.sin()] + }; + let seg = |a: [f32; 2], b: [f32; 2], n: usize| -> Vec<[f32; 2]> { + (0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect() + }; + let (e1, e2) = (end(barbs[0], 1.0), end(barbs[1], -1.0)); + if round_tip { + pts.extend([[tip[0] + dir[0] * 1.0 + normal[0] * 1.5, tip[1] + dir[1] * 1.0 + normal[1] * 1.5]]); + } else { + pts.push(tip); + } + pts.extend(seg(tip, e1, m)); + pts.extend(seg(e1, tip, m)); + if round_tip { + pts.push([tip[0] + dir[0] * 1.0 - normal[0] * 1.5, tip[1] + dir[1] * 1.0 - normal[1] * 1.5]); + } + pts.extend(seg(tip, e2, m)); + pts.push(e2); + pts + } + + #[test] + fn hand_drawn_arrows_with_bowed_wobbly_shafts_rounded_tips_and_uneven_barbs_are_arrows() { + let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); + let sym = [(25.0, 30.0), (25.0, 30.0)]; + let is_arrow = |pts: &[[f32; 2]]| matches!(recognise(pts), Some(InkShape::Arrow { .. })); + // Wobble alone (about 0.6 pt), a 4 pt sag over 160 pt, and both bowed the other way. + assert!(is_arrow(&hand_arrow(tail, tip, 0.0, false, sym)), "wobble"); + assert!(is_arrow(&hand_arrow(tail, tip, 4.0, false, sym)), "bowed"); + assert!(is_arrow(&hand_arrow(tail, tip, -4.0, false, sym)), "bowed the other way"); + // A rounded tip, and slightly asymmetric barbs (20 at 25 degrees, 30 at 40). + assert!(is_arrow(&hand_arrow(tail, tip, 0.0, true, sym)), "rounded tip"); + assert!(is_arrow(&hand_arrow(tail, tip, 2.0, false, [(20.0, 25.0), (30.0, 40.0)])), "asymmetric"); + // All at once, going up and to the left, and head first. + let all = hand_arrow([180.0, 160.0], [40.0, 30.0], 4.0, true, [(20.0, 25.0), (30.0, 40.0)]); + let Some(InkShape::Arrow { a, b, .. }) = recognise(&all) else { panic!("{:?}", recognise(&all)) }; + assert!(dist(a, [180.0, 160.0]) < 3.0 && dist(b, [40.0, 30.0]) < 3.0, "{a:?} {b:?}"); + let mut rev = all; + rev.reverse(); + assert!(is_arrow(&rev), "head first"); + } + + #[test] + fn the_barb_limits_are_the_ones_that_are_detected() { + let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); // a 160 pt shaft + let is_arrow = |l: f32, deg: f32| matches!(recognise(&arrow_stroke(tail, tip, l, deg)), Some(InkShape::Arrow { .. })); + // Length 5 to 50 % of the shaft (8 to 80 pt), angle 15 to 75 degrees. + assert!(is_arrow(8.5, 30.0) && is_arrow(79.0, 30.0)); + assert!(!is_arrow(6.0, 30.0) && !is_arrow(88.0, 30.0)); + assert!(is_arrow(25.0, 17.0) && is_arrow(25.0, 73.0)); + assert!(!is_arrow(25.0, 11.0) && !is_arrow(25.0, 80.0)); + } + + #[test] + fn letters_and_hooks_are_not_arrows() { + // A "Y" in both orders: arm to the fork, down the stem; stem up, then the other arm. + let y_down = [[40.0, 0.0], [60.0, 30.0], [80.0, 0.0], [60.0, 30.0], [60.0, 90.0]]; + assert_eq!(recognise(&y_down), None); + let y_up = [[60.0, 90.0], [60.0, 30.0], [40.0, 0.0], [60.0, 30.0], [80.0, 0.0]]; + assert_eq!(recognise(&y_up), None); + // A print "k": stem down, up to the middle, out to the upper right, back, out to the lower right. + let k = [[0.0, 0.0], [0.0, 100.0], [0.0, 60.0], [40.0, 30.0], [0.0, 60.0], [40.0, 100.0]]; + assert_eq!(recognise(&k), None); + // A "k" drawn top, middle, upper right, middle, lower right. + let k2 = [[0.0, 0.0], [0.0, 60.0], [40.0, 30.0], [0.0, 60.0], [40.0, 100.0]]; + assert_eq!(recognise(&k2), None); + // A checkmark, an "L", a "V" and a line with one barb. + assert_eq!(recognise(&[[0.0, 40.0], [20.0, 70.0], [90.0, 0.0]]), None); + assert_eq!(recognise(&[[0.0, 0.0], [0.0, 80.0], [50.0, 80.0]]), None); + let hook = &arrow_stroke([20.0, 100.0], [180.0, 100.0], 25.0, 30.0)[..34]; + assert_eq!(recognise(hook), None); + let zig: Vec<[f32; 2]> = (0..9).map(|i| [i as f32 * 20.0, if i % 2 == 0 { 0.0 } else { 40.0 }]).collect(); + assert_eq!(recognise(&zig), None); + } + + #[test] + fn a_return_that_overshoots_the_tip_still_makes_an_arrow() { + let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); + // Shaft to the tip, first barb, back past the tip by 2 pt, then the second barb. + let barb = |sign: f32| { + let a = std::f32::consts::PI + sign * 30.0f32.to_radians(); + [tip[0] + 25.0 * a.cos(), tip[1] + 25.0 * a.sin()] + }; + let over = [tip[0] + 2.0, tip[1]]; + let line = |a: [f32; 2], b: [f32; 2], n: usize| -> Vec<[f32; 2]> { + (0..n).map(|k| k as f32 / n as f32).map(|t| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]).collect() + }; + let mut stroke = line(tail, tip, 24); + stroke.extend(line(tip, barb(1.0), 5)); + stroke.extend(line(barb(1.0), over, 5)); + stroke.extend(line(over, barb(-1.0), 5)); + stroke.push(barb(-1.0)); + let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{:?}", recognise(&stroke)) }; + assert!(dist(a, tail) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?}"); + // Head first: the same stroke backwards. + stroke.reverse(); + let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{:?}", recognise(&stroke)) }; + assert!(dist(a, tail) < 3.0 && dist(b, tip) < 3.0, "{a:?} {b:?}"); + } + + #[test] + fn densely_sampled_arrows_keep_their_tip_and_barb_limits() { + // A pen samples about every point; the tip must still be where the head was drawn. + for (tail, tip) in [([20.0, 100.0], [180.0, 100.0]), ([100.0, 180.0], [100.0, 20.0]), ([150.0, 120.0], [30.0, 30.0])] { + let stroke = dense_arrow(tail, tip, 25.0, 30.0); + let Some(InkShape::Arrow { a, b, .. }) = recognise(&stroke) else { panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke)) }; + assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "{a:?} {b:?} for {tail:?} {tip:?}"); + 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) < 1.0 && dist(b, tip) < 1.0, "head first {a:?} {b:?}"); + } + // The documented limits hold on dense strokes too: 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(&dense_arrow(tail, tip, l, deg)), Some(InkShape::Arrow { .. })); + assert!(is_arrow(8.5, 30.0) && is_arrow(8.5, 45.0) && is_arrow(8.5, 17.0) && is_arrow(79.0, 30.0), "lengths"); + assert!(is_arrow(12.0, 30.0) && is_arrow(12.0, 45.0), "small barbs"); + assert!(is_arrow(25.0, 17.0) && is_arrow(25.0, 70.0) && is_arrow(25.0, 73.0), "angles"); + assert!(!is_arrow(6.0, 30.0) && !is_arrow(88.0, 30.0)); + assert!(!is_arrow(25.0, 11.0) && !is_arrow(25.0, 80.0)); + // A dense line with one barb (a hook) is still not an arrow. + let hook = dense_arrow(tail, tip, 25.0, 30.0); + assert!(!matches!(recognise(&hook[..186]), Some(InkShape::Arrow { .. })), "{:?}", recognise(&hook[..186])); + } + + #[test] + fn densely_sampled_hand_drawn_arrows_and_overshoots_are_arrows() { + let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]); + let sym = [(25.0, 30.0), (25.0, 30.0)]; + let dense = |bow: f32, round: bool, barbs: [(f32, f32); 2]| hand_arrow_n(tail, tip, bow, round, barbs, 160, 25); + let tip_of = |pts: &[[f32; 2]]| match recognise(pts) { + Some(InkShape::Arrow { b, .. }) => Some(b), + _ => None, + }; + for (what, pts) in [ + ("wobble", dense(0.0, false, sym)), + ("bowed", dense(4.0, false, sym)), + ("bowed the other way", dense(-4.0, false, sym)), + ("rounded tip", dense(0.0, true, sym)), + ("asymmetric", dense(2.0, false, [(20.0, 25.0), (30.0, 40.0)])), + ("small", dense(0.0, false, [(10.0, 45.0), (10.0, 45.0)])), + ("wide", dense(0.0, false, [(25.0, 70.0), (25.0, 70.0)])), + ] { + let b = tip_of(&pts); + assert!(b.is_some_and(|b| dist(b, tip) < 2.0), "{what}: {b:?}"); + let mut rev = pts; + rev.reverse(); + assert!(tip_of(&rev).is_some(), "{what} head first"); + } + // Back past the tip by 2 pt between the barbs, densely sampled, both ways. + let barb = |sign: f32| { + let a = std::f32::consts::PI + sign * 30.0f32.to_radians(); + [tip[0] + 25.0 * a.cos(), tip[1] + 25.0 * a.sin()] + }; + let over = [tip[0] + 2.0, tip[1]]; + let mut stroke = line(tail, tip, 160); + stroke.extend(line(tip, barb(1.0), 25)); + stroke.extend(line(barb(1.0), over, 25)); + stroke.extend(line(over, barb(-1.0), 25)); + stroke.push(barb(-1.0)); + let b = tip_of(&stroke); + assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot: {b:?}"); + stroke.reverse(); + let b = tip_of(&stroke); + assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot head first: {b:?}"); + } } diff --git a/docs/gaps.md b/docs/gaps.md index c10057080..fe2ed20f6 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-14 · **Change:** trivial (RTF tracked insertions and deletions, gap 19; previously trivial: #19 narrowed: ODT sections on import, #1379; previously trivial: #16: PDF export's right-to-left text extracts as whole words, #1383; previously trivial: Word 365 cover page positions and closed freeforms, #743; previously trivial: RTF import keeps sections, #1378; 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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (RTF tracked insertions and deletions, gap 19; previously trivial: Ink to Shape recognises arrows; 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. @@ -27,7 +27,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 10 | **Objects**: no group (#267 open), 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 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~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) | | 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) | -| 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); missing: Ink to Math, arrows in Ink to Shape, 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 9/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); 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 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-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**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | @@ -90,6 +90,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-14 | trivial | RTF import keeps sections (#1378): gap 19 narrowed to RTF section export and ODT sections | | 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps | | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | +| 2026-10-14 | trivial | #12 narrowed: Ink to Shape recognises single-stroke arrows | | 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells (into or out of a table, whole rows); removed from the UI/UX gaps | | 2026-10-11 | trivial | Tables: selection areas, insert buttons, move handle and double-click AutoFit on the page landed; ordinary drags over cells of several rows (a run of text, not a rectangle) added to the UI/UX gaps | | 2026-10-13 | trivial | #1: Word 365 cover page (#743): shapes placed relative to the page (`wp14:pctPosVOffset`) take their offset; freeforms drawn back to their start are filled | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index bd4dfa15e..1296588f0 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-12 · **Change:** 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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; 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. @@ -15,7 +15,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) | | Multiple monitors, per-monitor DPI, remembered window position | ✅ | ✅ (#38, #76) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 | -| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | ❌ | ❌ | ❌ | ❌ | 0% | (Draw tab, 15–25) | +| 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) | | Microphone: Dictate | ✅ (cloud speech service) | ❌ | ❌ | ❌ | ❌ | 0% | 8–15 + owner (speech model choice) | | Speakers: Read Aloud | ✅ | ✅ system speech, speed, sentence skip (#190) | ✅ | 🟡 | 🟡 | 75% | 1–2 | | Touch screens and touchpads: pinch zoom, inertial scroll | ✅ | ✅ pinch (#179) | ✅ | ✅ inertial scroll (#252) | ✅ | 85% | 0–1 | @@ -25,6 +25,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Date | Change | Summary | |---|---|---| +| 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-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 | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. The upstream preview, page ranges, parity and Print Markup remain available. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. | | 2026-10-10 | trivial | Windows falls back to OpenGL when DirectX 12 offers only the software adapter (#316) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index baa2d3c97..301c22395 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; 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: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Ink to Shape recognises arrows; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; 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: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -72,7 +72,7 @@ full parity for the area. | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. | | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | -| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts: Insert › Chart makes column, bar (clustered and stacked), line, area, pie, doughnut and scatter charts with Chart Design (change type, Edit Data grid, title, legend, data labels) and saves them as real chart parts (#409); charts from Word are drawn (#292) and kept on save (#319) but stay read-only; SmartArt is drawn and kept (#292, #319); Insert › SmartArt (#497) creates and edits list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics with our own layouts, a Text Pane and the SmartArt Design tab, saved as diagram parts with the drawing; SmartArt from other programs stays read-only. | +| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, arrow, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts: Insert › Chart makes column, bar (clustered and stacked), line, area, pie, doughnut and scatter charts with Chart Design (change type, Edit Data grid, title, legend, data labels) and saves them as real chart parts (#409); charts from Word are drawn (#292) and kept on save (#319) but stay read-only; SmartArt is drawn and kept (#292, #319); Insert › SmartArt (#497) creates and edits list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics with our own layouts, a Text Pane and the SmartArt Design tab, saved as diagram parts with the drawing; SmartArt from other programs stays read-only. | | View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Master Document, Immersive Reader depth. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | @@ -286,6 +286,7 @@ can be used without committing Word output. | 2026-10-11 | trivial | References: all 12 of Word's bibliography styles (eight more: Harvard, ISO 690 ×2, Turabian, GB/T 7714, GOST ×2, SIST02) (#383); no percentage change | | 2026-10-11 | trivial | Mail merge fixes (#427–#430): SKIPIF drops matching records, NEXT advances later fields, IF previews, header/footer fields merge; Mailings row updated, no percentage change | | 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change | +| 2026-10-14 | trivial | Ink to Shape recognises single-stroke arrows | | 2026-10-11 | trivial | #395: Lasso Select, Add Pen, Ink to Shape and Ink Replay; catalog 423/448 → 427/448 (95.3%) | | 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) | | 2026-10-11 | trivial | Vertical sections (#491): Page layout and East Asian rows updated; no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 97a56f7cc..5fe578d77 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-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: alignment guides while dragging pictures and shapes; previously trivial: Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: web picture paste only lands when the page is the edit target, #1349; 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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: Ink to Shape recognises arrows; 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 @@ -10,7 +10,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Area | WordCraft | Word | Parity | Hours | |---|---|---|---|---| -| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape, Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math | Same plus Draw | 85% | (Draw in features) | +| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math | Same plus Draw | 85% | (Draw in features) | | Keytips (Alt / F10) | Ribbon badges (#43) and numbered Quick Access Toolbar badges in the user's button order (#620); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone | ✅ | 85% | 1–2 | | Mini-toolbar on selection | ✅ (#43, two rows #535) | ✅ | 80% | 1–2 | | Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 | @@ -147,5 +147,6 @@ Quick Access Toolbar labels are generated from the current button order. For mor buttons, the remaining labels start with zero and use the same number of digits (for example `01`, `02` on a ten-button toolbar; `001` through `011` on a twenty-button toolbar). This keeps labels unambiguous. Those extra-number assignments have not been compared with Word. +| 2026-10-14 | trivial | Draw: Ink to Shape recognises single-stroke arrows | | 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) | | 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 | From 4d79e18ae8e26e4d4d0a24b5ceac86233927ed07 Mon Sep 17 00:00:00 2001 From: mjohnson7cd <61306576+mjohnson7cd@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:29:11 -0400 Subject: [PATCH 086/199] fix(math): preserve equation text color on save/load (#1467) (#1525) Lift equation color from run-level OMML to CharProps during read, push it back to runs during write. Keep source OMML until the color changes to preserve control properties the model can't hold. --- crates/doc/src/math_edit.rs | 20 ++++++++++++++ crates/docx/src/read/math.rs | 14 ++++++++++ crates/docx/src/read/mod.rs | 2 +- crates/docx/src/read/story.rs | 15 ++++++++--- crates/docx/src/write/math.rs | 34 ++++++++++++++++++++++-- crates/docx/src/write/story.rs | 10 ++++--- crates/engine/src/cmd/equation.rs | 43 +++++++++++++++++++++++++++++++ 7 files changed, 128 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/math_edit.rs b/crates/doc/src/math_edit.rs index b402ca71f..8290b8176 100644 --- a/crates/doc/src/math_edit.rs +++ b/crates/doc/src/math_edit.rs @@ -362,6 +362,26 @@ pub fn tidy(root: &mut Arg) { go(root, 0); } +/// Call `f` on every run in the tree. +pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) { + fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) { + if depth > MAX_DEPTH { + return; + } + for n in a.iter_mut() { + if let MNode::Run(r) = n { + f(r); + } + for c in 0..child_count(n) { + if let Some(ch) = child_mut(n, c) { + go(ch, f, depth + 1); + } + } + } + } + go(root, f, 0); +} + fn merge_shallow(a: Arg) -> Arg { let mut out: Arg = Vec::with_capacity(a.len()); for n in a { diff --git a/crates/docx/src/read/math.rs b/crates/docx/src/read/math.rs index c6a8adc36..71d4eef87 100644 --- a/crates/docx/src/read/math.rs +++ b/crates/docx/src/read/math.rs @@ -12,6 +12,20 @@ pub fn read_omath(e: &El, jc: MathJc) -> Math { Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false } } +/// A colour every math run shares, taken off the runs: it is the equation's own colour, as +/// [`write_equation`](crate::write::math::write_equation) spreads it (#1467). The source OMML +/// is kept: the writer replaces it only when the colour changes. +pub fn lift_color(math: &mut Math) -> Option { + let (mut shared, mut uniform) = (None, true); + wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| match shared { + None => shared = Some(r.color), + Some(c) => uniform &= c == r.color, + }); + let c = shared.flatten().filter(|_| uniform)?; + wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| r.color = None); + Some(c) +} + /// `m:oMathParaPr/m:jc`. pub fn para_jc(para: &El) -> MathJc { match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr("m:val")) { diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 9d9499066..a9bd6d8b7 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -5,7 +5,7 @@ pub(crate) mod diagram; mod drawing_color; mod embed; mod freeform; -mod math; +pub(crate) mod math; mod props; mod sdt; mod shape_style; diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 392c93959..0df872a7f 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -7,7 +7,7 @@ use wordcraft_doc::control::{ContentControl, ControlKind, ControlWrap}; use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind}; use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText, cap_alt}; -use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb}; +use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb, TextColor}; use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table}; use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block}; @@ -496,8 +496,11 @@ impl Reader<'_> { } "w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1), "m:oMath" => { - let props = self.run_props_none(ctx); - let math = super::math::read_omath(k, Default::default()); + let mut props = self.run_props_none(ctx); + let mut math = super::math::read_omath(k, Default::default()); + if let Some(c) = super::math::lift_color(&mut math) { + props.color = Some(TextColor::Rgb(c)); + } let linear = wordcraft_doc::math::to_linear(&math.nodes); self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props); } @@ -505,7 +508,11 @@ impl Reader<'_> { let props = self.run_props_none(ctx); let jc = super::math::para_jc(k); for m in k.children("m:oMath") { - let math = super::math::read_omath(m, jc); + let mut math = super::math::read_omath(m, jc); + let mut props = props.clone(); + if let Some(c) = super::math::lift_color(&mut math) { + props.color = Some(TextColor::Rgb(c)); + } let linear = wordcraft_doc::math::to_linear(&math.nodes); self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props); } diff --git a/crates/docx/src/write/math.rs b/crates/docx/src/write/math.rs index c6ec3642f..a1ae2a8c5 100644 --- a/crates/docx/src/write/math.rs +++ b/crates/docx/src/write/math.rs @@ -3,12 +3,32 @@ use wordcraft_doc::math::{ Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear, }; +use wordcraft_doc::props::Rgb; use crate::xml::W; /// Write an equation: `m:oMath`, inside `m:oMathPara` for a display equation. The source OMML -/// is written back unchanged when the equation came from a file. -pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math) { +/// is written back unchanged when the equation came from a file. `color` (the equation's run +/// colour) goes on every math run without its own: OMML has no equation-level colour (#1467). +pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math, color: Option) { + let tinted; + let math = if color == omml_color(&math.omml) { + math + } else { + // The kept source XML has the old colour baked in: write from the nodes instead. + let mut m = math.clone(); + if m.nodes.is_empty() { + m.nodes = parse_linear(linear); + } + if let Some(c) = color { + wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| { + r.color.get_or_insert(c); + }); + } + m.omml.clear(); + tinted = m; + &tinted + }; if display { w.open("m:oMathPara", &[]); let jc = match math.jc { @@ -82,6 +102,16 @@ pub fn resolve_numbers(math: &Math, display: bool, counter: &mut u32) -> Option< changed.then(|| Math { nodes: vec![MNode::EqArr { rows: out }], omml: String::new(), ..math.clone() }) } +/// The equation colour the kept source OMML carries, as the reader lifts it. +fn omml_color(omml: &str) -> Option { + if omml.is_empty() { + return None; + } + let root = crate::xml::parse(wrap_ns(omml).as_bytes()).ok()?; + let e = root.child("m:oMath")?; + crate::read::math::lift_color(&mut crate::read::math::read_omath(e, MathJc::default())) +} + /// The kept XML with namespace declarations, for validating it on its own. fn wrap_ns(xml: &str) -> String { let decl: String = crate::xml::body_ns().iter().filter(|(k, _)| k.starts_with("xmlns:")).map(|(k, v)| format!(" {k}=\"{v}\"")).collect(); diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 07e31d4ec..702c94413 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -2,7 +2,7 @@ use wordcraft_doc::effects::ShapeEffects; use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap}; -use wordcraft_doc::props::{CharProps, PropChange, Rgb}; +use wordcraft_doc::props::{CharProps, PropChange, Rgb, TextColor}; use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart}; use wordcraft_doc::table::Table; use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind}; @@ -472,9 +472,13 @@ impl Writer<'_> { } InlineObject::Equation { linear, display, math } => { // Automatic equation numbers become text: Word numbers nothing by itself. + let color = match props.color { + Some(TextColor::Rgb(c)) => Some(c), + _ => None, + }; match super::math::resolve_numbers(math, *display, &mut self.eq_number) { - Some(m) => super::math::write_equation(w, linear, *display, &m), - None => super::math::write_equation(w, linear, *display, math), + Some(m) => super::math::write_equation(w, linear, *display, &m, color), + None => super::math::write_equation(w, linear, *display, math, color), } } InlineObject::Field { instr, result, locked, .. } => self.field(w, instr, result, *locked, props), diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs index 51b3bdb6d..5371705e7 100644 --- a/crates/engine/src/cmd/equation.rs +++ b/crates/engine/src/cmd/equation.rs @@ -802,6 +802,49 @@ mod tests { assert_eq!(linear(&s), "a"); } + #[test] + fn colour_survives_save_and_reopen() { + // #1467: a coloured equation came back black after saving and reopening. + let mut s = session(); + s.run("insert.equation", &json!({"linear": "(a+b)/c"})).unwrap(); + s.run("equation.exit", &json!({})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.color", &json!({"color": "C00000"})).unwrap(); + assert!(document_xml(&s).contains(r#" 0); + // Automatic again: no colour written. + back.run("select.all", &json!({})).unwrap(); + back.run("format.color", &json!({"color": "auto"})).unwrap(); + assert!(!document_xml(&back).contains("C00000")); + } + + #[test] + fn imported_colour_keeps_source_omml_until_recoloured() { + // #1467: lifting a Word equation's colour must not drop the OMML the model can't hold. + let r = r#""#; + let ctrl = r#""#; + let mut s = + imported(&format!("{ctrl}{r}a{r}b")); + assert!(document_xml(&s).contains("00FF00"), "untouched: source OMML written back"); + s.run("select.all", &json!({})).unwrap(); + s.run("format.color", &json!({"color": "0000FF"})).unwrap(); + let xml = document_xml(&s); + assert!(xml.contains("0000FF") && !xml.contains("FF0000"), "recoloured: runs rewritten"); + } + #[test] fn navigation_keeps_imported_omml() { // #446: a Left move without any change dropped the fraction's imported control colour. From 2814dfcae9c2d1b71b70b72aa234f54192a7a1af Mon Sep 17 00:00:00 2001 From: Eduardo Garcia Date: Wed, 14 Oct 2026 15:29:23 -0300 Subject: [PATCH 087/199] fix(proof): British spellings such as honour and prioritise are not flagged (#40) (#1536) The bundled Moby list already knows many British forms (judgement, defence, programme) but still flagged others: honour and its inflections, colourful, colourless, rigour, glamour, prioritise, privatise, synthesise, paediatric and a few more. Adds those base forms to assets/spelling/en-extra.txt (inflections come from the existing rules) and a regression test. A separate en-GB dictionary chosen from w:lang is still open on the issue. Co-authored-by: Claude Sonnet 5.5 --- ATTRIBUTION.md | 2 +- assets/spelling/en-extra.txt | 17 +++++++++++++++++ crates/proof/src/lib.rs | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 7942228d6..66dcdfa4b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -10,7 +10,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra |---|---|---|---|---| | `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) | | `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation | -| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) | +| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) | | `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation | | `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation | | `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation | diff --git a/assets/spelling/en-extra.txt b/assets/spelling/en-extra.txt index 41567c7d7..7d04908e3 100644 --- a/assets/spelling/en-extra.txt +++ b/assets/spelling/en-extra.txt @@ -26,7 +26,12 @@ chatroom checkbox checklist childcare +clamour clickable +colourful +colourisation +colourise +colourless configurable copyedit coronavirus @@ -54,6 +59,7 @@ ecommerce email emoji emoticon +endeavour esports facebook favicon @@ -71,6 +77,7 @@ geolocation gigabit gigabyte github +glamour globalization google groupthink @@ -80,6 +87,9 @@ hashtag healthcare homepage homeschool +honour +honourable +honourably hotspot hyperlink hypertext @@ -106,6 +116,7 @@ logout lowercase makeover malware +manoeuvrable megabyte megapixel meme @@ -127,6 +138,7 @@ onboard online onscreen outsource +paediatric paralegal passcode petabyte @@ -139,10 +151,13 @@ podcast popup prequel printout +prioritise prioritization prioritize +privatise ransomware recyclable +rigour ringtone roadmap rollout @@ -171,6 +186,7 @@ subfolder sunscreen superfood superuser +synthesise tagline takeout techie @@ -188,6 +204,7 @@ touchpad touchscreen trackball trackpad +travelogue upcycle upload uppercase diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 671a0e803..6b34a4a5d 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -812,6 +812,43 @@ mod tests { } /// The supplement stays a sorted list of lowercase base forms. + /// #40: British spellings and their inflections are not flagged. + #[test] + fn british_spellings_are_known() { + for w in [ + "judgement", + "defence", + "licence", + "programme", + "catalogue", + "behaviour", + "colour", + "colourful", + "colourless", + "honour", + "honoured", + "honouring", + "honours", + "honourable", + "rigour", + "glamour", + "clamour", + "endeavour", + "paediatric", + "prioritise", + "prioritised", + "prioritising", + "privatise", + "privatised", + "synthesise", + "travelogue", + "manoeuvrable", + ] { + assert!(is_correct(w), "{w}"); + } + assert!(check_spelling("The programme honoured her rigour and prioritised colourful, paediatric care.").is_empty()); + } + #[test] fn supplement_is_sorted_lowercase() { let words: Vec<&str> = EXTRA_EN.lines().filter(|l| !l.is_empty() && !l.starts_with('#')).collect(); From e7e4522d0d461f10ee7d567253014db6dcc40eca Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 20:45:07 +0200 Subject: [PATCH 088/199] ODT: read and write tracked changes (text:tracked-changes) (#1517) * ODT: read and write tracked changes (gap 19) Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md # crates/engine/src/tests.rs # crates/formats/src/model.rs # crates/formats/src/odt.rs # docs/file-format-parity.md # docs/gaps.md * ODT tracked changes: deleted paragraph marks don't take the body's insertion; one region per deletion Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + ROADMAP.md | 4 +- crates/engine/src/tests.rs | 86 ++++ crates/formats/Cargo.toml | 4 + crates/formats/src/model.rs | 23 +- crates/formats/src/odt.rs | 860 +++++++++++++++++++++++++++++++++++- crates/formats/src/rtf.rs | 2 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 9 files changed, 961 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 503ba4373..9c4db4969 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5238,6 +5238,7 @@ name = "wordcraft-formats" version = "0.7.0" dependencies = [ "image", + "js-sys", "log", "proptest", "quick-xml", diff --git a/ROADMAP.md b/ROADMAP.md index df85ead13..1bed44d9c 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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: Ink to Shape arrows; 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: ODT tracked changes, gap 19; previously trivial: progress log: RTF tracked changes, gap 19; previously trivial: progress log: 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. @@ -156,6 +156,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) | | 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) | | 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake | +| 2026-10-14 | ODT files keep their tracked changes (gap 19): insertions and deletions made in LibreOffice open as revisions with their author and date, deleted paragraph breaks and text inserted then deleted included, so Accept All and Reject All give the right text; saving to ODT writes them back, and the Track Changes setting goes both ways | | 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening | | 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides | | 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX | @@ -337,6 +338,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) | | 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) | | 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) | +| 2026-10-14 | trivial | Progress log: ODT tracked changes (gap 19) | | 2026-10-14 | trivial | Progress log: RTF tracked insertions and deletions (gap 19) | | 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes | | 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index db442f84d..136d52cdb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5903,3 +5903,89 @@ fn exported_page_images_draw_headers_at_full_strength() { assert!(darkest < 60, "header drawn dimmed: darkest pixel {darkest}"); let _ = std::fs::remove_dir_all(&dir); } + +/// Gap 19, review: text inserted by one author and deleted by another keeps both marks through +/// an ODT save, so Reject All (undo the deletion, then the insertion) and Accept All both remove it. +#[test] +fn odt_inserted_then_deleted_text_round_trips() { + use std::io::Write; + let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#; + let info = |who: &str| format!("{who}2024-03-01T10:00:00"); + let content = format!( + r#"{}{}goneKeep this."#, + info("Alice"), + info("Bob") + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] { + z.start_file(name, o).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); + let doc = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap(); + for cmd in ["review.rejectAll", "review.acceptAll"] { + let mut s = Session::new(doc.clone()); + run(&mut s, cmd, json!({})); + assert_eq!(text(&s), "Keep this.", "{cmd}"); + } +} + +/// Gap 19: an ODT's tracked insertion and multi-paragraph deletion come in as revisions that +/// Reject All turns back into the original and Accept All into the edited text. +#[test] +fn odt_tracked_changes_accept_and_reject_all() { + use std::io::Write; + let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#; + let info = |who: &str| format!("{who}2024-03-01T10:00:00"); + let content = format!( + r#"{}{}tailheadFirst rest added.End."#, + info("Alice"), + info("Bob") + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] { + z.start_file(name, o).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + let bytes = z.finish().unwrap().into_inner(); + let doc = crate::io::open_bytes("t.odt", &bytes).unwrap(); + + let mut sr = Session::new(doc.clone()); + run(&mut sr, "review.rejectAll", json!({})); + assert_eq!(text(&sr), "First tail\nhead rest .\nEnd."); + assert!(sr.doc.revisions.is_empty() || run(&mut sr, "review.changes", json!({})).as_array().unwrap().is_empty()); + + let mut sa = Session::new(doc); + run(&mut sa, "review.acceptAll", json!({})); + assert_eq!(text(&sa), "First rest added.\nEnd."); + assert!(run(&mut sa, "review.changes", json!({})).as_array().unwrap().is_empty()); +} + +/// Gap 19, review 2: a paragraph break deleted inside an insertion range is not itself +/// inserted, so Reject All keeps the two paragraphs (after an ODT save, too). +#[test] +fn odt_deleted_mark_inside_an_insertion_survives_reject_all() { + use std::io::Write; + let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#; + let info = |who: &str| format!("{who}2024-03-01T10:00:00"); + let content = format!( + r#"{}{}One AC two"#, + info("Alice"), + info("Bob") + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] { + z.start_file(name, o).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); + let saved = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap(); + for d in [doc, saved] { + let mut s = Session::new(d); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(text(&s), "One \n two"); + } +} diff --git a/crates/formats/Cargo.toml b/crates/formats/Cargo.toml index 393599097..a892251ed 100644 --- a/crates/formats/Cargo.toml +++ b/crates/formats/Cargo.toml @@ -16,5 +16,9 @@ quick-xml = { workspace = true } zip = { workspace = true } log = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +# The browser clock (`SystemTime::now()` panics on wasm32): ODT change dates. +js-sys = "0.3" + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index feb78a0b4..8ab44b70c 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -48,6 +48,10 @@ pub struct Fmt { pub struct RevMark { pub author: String, pub date: String, + /// The source's identity of the change, when it has one (an ODT changed region; the + /// revision index in [`from_doc_tracked`]): marks with different ids stay separate + /// revisions. `None` (RTF) = one revision per kind, author and date. + pub id: Option, } /// An embedded picture. @@ -125,6 +129,11 @@ pub struct Para { pub borders: Option, /// The section this paragraph ends (RTF `\sect`), as the document's last paragraph of it. pub section: Option>, + /// Tracked insertion of the paragraph mark. + pub mark_ins: Option>, + /// Tracked deletion of the paragraph mark (only [`from_doc_tracked`] and formats that keep + /// revisions set it). + pub mark_del: Option>, } impl Para { @@ -414,7 +423,7 @@ impl<'a> Builder<'a> { let mut revs = std::collections::HashMap::new(); for (i, r) in doc.revisions.iter().enumerate() { if r.move_name.is_none() { - let mark = RevMark { author: r.author.clone(), date: r.date.clone() }; + let mark = RevMark { author: r.author.clone(), date: r.date.clone(), id: None }; revs.entry((rev_key(r.kind), mark)).or_insert(i as u32); } } @@ -500,6 +509,8 @@ impl<'a> Builder<'a> { } } } + out.mark.ins = p.mark_ins.as_deref().map(|m| self.revision(RevisionKind::Insert, m)); + out.mark.del = p.mark_del.as_deref().map(|m| self.revision(RevisionKind::Delete, m)); out.normalize(); out.touch(); out @@ -744,7 +755,7 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt { /// The insertion and deletion `cp` tracks, as flow marks. fn rev_marks(doc: &Document, cp: &CharProps) -> (Option, Option) { let mark = |i: Option| { - i.map(|i| doc.revisions.get(i as usize).map(|r| RevMark { author: r.author.clone(), date: r.date.clone() }).unwrap_or_default()) + i.map(|i| doc.revisions.get(i as usize).map(|r| RevMark { author: r.author.clone(), date: r.date.clone(), id: Some(i) }).unwrap_or_default()) }; (mark(cp.ins), mark(cp.del)) } @@ -807,6 +818,8 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { spacing: Spacing::default(), borders: None, section: None, + mark_ins: None, + mark_del: None, }; let in_code = kind == Kind::Code; let mut k = 0usize; @@ -833,6 +846,8 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { spacing: Spacing::default(), borders: None, section: None, + mark_ins: None, + mark_del: None, }; let prev = std::mem::replace(&mut out, next); if prev.inlines.is_empty() { @@ -898,6 +913,10 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { out.push_text(&buf, &f); } } + if tracked { + let (ins, del) = rev_marks(doc, &p.mark); + (out.mark_ins, out.mark_del) = (ins.map(Box::new), del.map(Box::new)); + } done.push(out); done } diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index fca92ab99..b13ca181a 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -8,8 +8,14 @@ //! Quotations, Preformatted Text, page layout) and `meta.xml`. //! //! Import reads the same subset back from `content.xml` / `styles.xml` (style inheritance -//! resolved, both automatic and common styles), skipping notes, annotations, text boxes and -//! tracked-change records. +//! resolved, both automatic and common styles), skipping notes, annotations and text boxes. +//! +//! Tracked changes (ODF 1.3 Part 3 §5.5) go both ways: `text:tracked-changes` holds one +//! `text:changed-region` per change; an insertion is the body text between +//! `text:change-start`/`text:change-end`, a deletion keeps the deleted paragraphs in its region +//! and leaves a `text:change` point in the body. They map to `Document::revisions` and the +//! `ins`/`del` marks of runs and paragraph marks; a deleted paragraph mark is a region of +//! several paragraphs. Format changes are read as no change. use std::collections::HashMap; use std::io::{Cursor, Read, Write}; @@ -17,9 +23,9 @@ use std::io::{Cursor, Read, Write}; use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; use wordcraft_doc::section::{Columns, SectionStart}; -use wordcraft_doc::{Align, Document, Rgb, SectionProps}; +use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps}; -use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, make_img, mime_of}; +use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of}; /// Largest zip entry we inflate. const MAX_ENTRY: u64 = 256 << 20; @@ -27,6 +33,12 @@ const MAX_ENTRY: u64 = 256 << 20; const MAX_XML_DEPTH: usize = 512; /// Most sections an import makes (page-style changes and column sections); later ones are ignored. const MAX_SECTIONS: usize = 10_000; +/// Most `text:changed-region`s read (hostile input). +const MAX_REGIONS: usize = 10_000; +/// Most insertions open at once (`text:change-start` without its end yet). +const MAX_OPEN_INSERTIONS: usize = 64; +/// Longest change author or date kept, in characters. +const MAX_INFO_CHARS: usize = 256; // --------------------------------------------------------------------------------------------- // Export @@ -79,6 +91,16 @@ struct Writer { /// Cell styles by shading. cell_styles: Vec>, pictures: Vec<(String, std::sync::Arc>, String)>, + /// `text:changed-region` elements written so far. + regions: Vec, + /// The insertion open in the body, and its region id. + open_ins: Option<(RevMark, String)>, + /// Style of the paragraph being written. + cur_style: String, + /// Styles of the paragraphs joined into the one being written (deleted paragraph marks), + /// and which of them the text being written belongs to. + chain: Vec, + part: usize, } fn text_props(f: &Fmt) -> String { @@ -181,7 +203,119 @@ impl Writer { name } - fn inlines(&mut self, inl: &[Inline], out: &mut String) { + /// A new `text:changed-region` of `kind` by `m` with `body` after its change info; its id. + fn region(&mut self, kind: &str, m: &RevMark, body: &str) -> String { + let id = format!("ct{}", self.regions.len() + 1); + // `dc:date` is required: a change without a date is dated now. + let date = if m.date.trim().is_empty() { now_iso() } else { m.date.clone() }; + self.regions.push(format!( + "{}{}{body}", + x(&m.author), + x(&date) + )); + id + } + + /// Make `m` the insertion open in `st` (closing another one first). + fn ins_open(&mut self, st: &mut Option<(RevMark, String)>, m: &RevMark, out: &mut String) { + if st.as_ref().is_some_and(|(o, _)| o == m) { + return; + } + ins_close(st, out); + let ct = self.region("insertion", m, ""); + out.push_str(&format!("")); + *st = Some((m.clone(), ct)); + } + + fn open_ins(&mut self, m: &RevMark, out: &mut String) { + let mut st = self.open_ins.take(); + self.ins_open(&mut st, m, out); + self.open_ins = st; + } + + fn close_ins(&mut self, out: &mut String) { + ins_close(&mut self.open_ins, out); + } + + /// Inlines with their insertions as change ranges kept in `st`. + fn ins_inlines(&mut self, items: &[Item<'_>], st: &mut Option<(RevMark, String)>, out: &mut String) { + let mut k = 0; + while k < items.len() { + let ins = items.get(k).and_then(|i| i.marks().0); + let mut j = k + 1; + while j < items.len() && items.get(j).and_then(|i| i.marks().0) == ins { + j += 1; + } + match ins { + Some(m) => self.ins_open(st, m, out), + None => ins_close(st, out), + } + let seg: Vec = items.get(k..j).unwrap_or(&[]).iter().filter_map(|i| i.inline().cloned()).collect(); + self.plain_inlines(&seg, out); + k = j; + } + } + + /// Deleted content: a region holding it (one paragraph per deleted paragraph mark plus + /// one; text inserted before it was deleted as change ranges inside) and a `text:change` + /// point in the body. + fn deletion(&mut self, seg: &[Item<'_>], m: &RevMark, out: &mut String) { + let mut body = String::new(); + let mut st: Option<(RevMark, String)> = None; + let mut start = 0; + let mut first = true; + loop { + let end = seg.iter().skip(start).position(|i| matches!(i, Item::Mark(_))).map(|e| start + e); + if !first { + self.part += 1; + } + first = false; + let style = self.chain.get(self.part).cloned().unwrap_or_else(|| self.cur_style.clone()); + body.push_str(&format!("")); + self.ins_inlines(seg.get(start..end.unwrap_or(seg.len())).unwrap_or(&[]), &mut st, &mut body); + // A deleted paragraph mark that had been inserted: the range runs on past it. + match end.and_then(|e| seg.get(e)).and_then(|i| i.marks().0) { + Some(ins) => self.ins_open(&mut st, ins, &mut body), + None => ins_close(&mut st, &mut body), + } + body.push_str(""); + match end { + Some(e) => start = e + 1, + None => break, + } + } + let ct = self.region("deletion", m, &body); + out.push_str(&format!("")); + } + + /// Paragraph content, with tracked insertions as change ranges and deletions as change points. + fn items(&mut self, items: &[Item<'_>], out: &mut String) { + let mut k = 0; + while k < items.len() { + let key = items.get(k).map(Item::segment).unwrap_or_default(); + let mut j = k + 1; + while j < items.len() && items.get(j).map(Item::segment).unwrap_or_default() == key { + j += 1; + } + let seg = items.get(k..j).unwrap_or(&[]); + match key { + (_, Some(del)) => self.deletion(seg, del, out), + (Some(ins), None) => { + self.open_ins(ins, out); + let inl: Vec = seg.iter().filter_map(|i| i.inline().cloned()).collect(); + self.plain_inlines(&inl, out); + } + (None, None) => { + self.close_ins(out); + let inl: Vec = seg.iter().filter_map(|i| i.inline().cloned()).collect(); + self.plain_inlines(&inl, out); + } + } + k = j; + } + } + + fn plain_inlines(&mut self, inl: &[Inline], out: &mut String) { let mut prev_space = true; let inl = model::equations_as_text(inl); for i in inl.iter() { @@ -256,23 +390,54 @@ impl Writer { } } - fn para(&mut self, p: &Para, out: &mut String) { + /// Paragraph `p`, or with `chain` the paragraphs joined at deleted paragraph marks (`p` + /// is the first). + fn para_in(&mut self, p: &Para, chain: Option<&Vec>, out: &mut String) { + let paras: &[Para] = chain.map(|c| c.as_slice()).unwrap_or(std::slice::from_ref(p)); + self.chain = chain.map(|c| c.iter().map(|q| self.para_style(q)).collect()).unwrap_or_default(); + self.part = 0; let style = self.para_style(p); - match p.kind { + self.cur_style = style.clone(); + let tag = match p.kind { Kind::Heading(n) => { out.push_str(&format!("", n.clamp(1, 6))); - self.inlines(&p.inlines, out); - out.push_str(""); + "" } _ => { out.push_str(&format!("")); - self.inlines(&p.inlines, out); - out.push_str(""); + "" } + }; + let tracked = paras.len() > 1 || p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) if f.ins.is_some() || f.del.is_some())); + if tracked { + let mut items = Vec::new(); + for (k, q) in paras.iter().enumerate() { + items.extend(q.inlines.iter().map(Item::Inl)); + if k + 1 < paras.len() { + items.push(Item::Mark(q)); + } + } + self.items(&items, out); + } else { + self.close_ins(out); + self.plain_inlines(&p.inlines, out); } + // An inserted paragraph mark: the insertion runs on past the paragraph's end. + match paras.last().and_then(|q| q.mark_ins.as_deref()) { + Some(m) => self.open_ins(m, out), + None => self.close_ins(out), + } + out.push_str(tag); + self.chain.clear(); } fn blocks(&mut self, blocks: &[FBlock], out: &mut String, depth: usize) { + let joined = join_deleted_marks(blocks); + let (blocks, chains) = match &joined { + Some((b, c)) => (b.as_slice(), Some(c)), + None => (blocks, None), + }; + let chain = |i: usize| chains.and_then(|c| c.get(&i)); let mut i = 0; while let Some(b) = blocks.get(i) { match b { @@ -305,7 +470,7 @@ impl Writer { let lname = format!("L{}", self.list_styles.len()); // Open lists: per level, whether a list-item is open. let mut stack: Vec = Vec::new(); - for q in blocks.get(start..i).unwrap_or(&[]) { + for (qi, q) in blocks.get(start..i).unwrap_or(&[]).iter().enumerate() { let FBlock::Para(q) = q else { continue }; let lv = q.list.map(|l| l.level.min(8) as usize).unwrap_or(0); while stack.len() > lv + 1 { @@ -335,7 +500,7 @@ impl Writer { *top = true; } out.push_str(""); - self.para(q, out); + self.para_in(q, chain(start + qi), out); } while let Some(open) = stack.pop() { if open { @@ -346,7 +511,7 @@ impl Writer { out.push('\n'); } FBlock::Para(p) => { - self.para(p, out); + self.para_in(p, chain(i), out); out.push('\n'); i += 1; } @@ -366,6 +531,7 @@ impl Writer { } fn table(&mut self, t: &FTable, out: &mut String, depth: usize) { + self.close_ins(out); let cols = t.cols().max(1); let widths: Vec = if t.widths.len() == cols && t.widths.iter().all(|w| w.is_finite() && *w > 0.0) { t.widths.clone() @@ -407,6 +573,7 @@ impl Writer { out.push_str(""); } self.blocks(&c.blocks, out, depth + 1); + self.close_ins(out); out.push_str(""); for _ in 1..span { out.push_str(""); @@ -481,6 +648,112 @@ impl Writer { } } +/// One piece of a paragraph being written: an inline, or the deleted mark of a paragraph +/// joined to the next one. +enum Item<'a> { + Inl(&'a Inline), + Mark(&'a Para), +} + +impl Item<'_> { + /// Its tracked insertion and deletion. + fn marks(&self) -> (Option<&RevMark>, Option<&RevMark>) { + match self { + Item::Inl(Inline::Text(_, f)) => (f.ins.as_ref(), f.del.as_ref()), + Item::Inl(_) => (None, None), + Item::Mark(p) => (p.mark_ins.as_deref(), p.mark_del.as_deref()), + } + } + + /// What a run of content is written as: deleted content is one region whatever was + /// inserted inside it, so only its deletion counts. + fn segment(&self) -> (Option<&RevMark>, Option<&RevMark>) { + match self.marks() { + (_, Some(del)) => (None, Some(del)), + k => k, + } + } + + fn inline(&self) -> Option<&Inline> { + match self { + Item::Inl(i) => Some(i), + Item::Mark(_) => None, + } + } +} + +/// Close the insertion open in `st`. +fn ins_close(st: &mut Option<(RevMark, String)>, out: &mut String) { + if let Some((_, ct)) = st.take() { + out.push_str(&format!("")); + } +} + +/// `blocks` with each run of paragraphs joined at tracked deleted paragraph marks (ODF keeps +/// such a deletion inside one body paragraph): the first paragraph stands for the run, and the +/// whole run is returned by its index. `None` when nothing is joined. A deleted mark with no +/// paragraph after it (before a table, at the end) is written as a plain mark. +#[allow(clippy::type_complexity)] +fn join_deleted_marks(blocks: &[FBlock]) -> Option<(Vec, HashMap>)> { + if !blocks.iter().any(|b| matches!(b, FBlock::Para(p) if p.mark_del.is_some())) { + return None; + } + let mut out = Vec::with_capacity(blocks.len()); + let mut chains = HashMap::new(); + let mut i = 0; + while let Some(b) = blocks.get(i) { + i += 1; + let FBlock::Para(p) = b else { + out.push(b.clone()); + continue; + }; + let mut chain = vec![p.clone()]; + while chain.last().is_some_and(|q| q.mark_del.is_some()) + && let Some(FBlock::Para(q)) = blocks.get(i) + { + chain.push(q.clone()); + i += 1; + } + if chain.len() > 1 { + chains.insert(out.len(), chain); + } + out.push(FBlock::Para(p.clone())); + } + Some((out, chains)) +} + +/// Now, as an `xsd:dateTime` (UTC). +fn now_iso() -> String { + let t = now_unix(); + let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + // Days since 1970-01-01 → civil date (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + 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 = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) +} + +/// Seconds since the Unix epoch: the system clock, or the browser's on the web. +fn now_unix() -> i64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()).unwrap_or(0) + } + // `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. + #[cfg(target_arch = "wasm32")] + { + let ms = js_sys::Date::now(); + if ms.is_finite() && ms > 0.0 { (ms / 1000.0) as i64 } else { 0 } + } +} + fn styles_xml(sec: &wordcraft_doc::SectionProps, body_font: &str) -> String { let mut s = format!("\n"); s.push_str(&format!( @@ -542,12 +815,18 @@ fn meta_xml(m: &Meta) -> String { /// Write an `.odt` package. pub fn export(doc: &Document) -> Result, String> { - let flow = model::from_doc(doc); + let flow = model::from_doc_tracked(doc); let mut w = Writer::default(); let mut body = String::new(); w.blocks(&flow.blocks, &mut body, 0); + w.close_ins(&mut body); + let tracked = if w.regions.is_empty() && !doc.settings.track_changes { + String::new() + } else { + format!("{}\n", doc.settings.track_changes, w.regions.concat()) + }; let content = format!( - "\n{}\n{body}\n", + "\n{}\n{tracked}{body}\n", w.automatic_styles() ); let body_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string()); @@ -991,6 +1270,37 @@ struct TableB { hcover: usize, } +/// A `text:changed-region` read from `text:tracked-changes`. +#[derive(Default)] +struct Region { + kind: Option, + /// Sequence number: tells regions by the same author at the same time apart. + id: u32, + author: String, + date: String, + /// A deletion's deleted paragraphs. + blocks: Vec, +} + +impl Region { + fn mark(&self) -> RevMark { + RevMark { author: self.author.trim().to_string(), date: self.date.trim().to_string(), id: Some(self.id) } + } +} + +/// Reader state saved while a deletion's content is read into its own container. +struct DelState { + containers: usize, + tables: usize, + cells: usize, + lists_saved: usize, + fmt: usize, + lists: Vec<(Option, bool)>, + item_used: Vec, + /// Insertions open inside the deleted content (text inserted, then deleted). + ins: Vec<(String, RevMark)>, +} + struct Body<'a> { styles: &'a Styles, zip: &'a mut dyn FnMut(&str) -> Option>, @@ -1020,6 +1330,23 @@ struct Body<'a> { sections: usize, /// Element depth of the open columned `text:section`. cols_at: Option, + /// Inside `text:tracked-changes`. + in_tc: bool, + /// `text:track-changes` of the document's tracked changes (default on), when it has them. + track: Option, + /// Changed regions read, by id. + regions: HashMap, + /// Regions seen (capped at [`MAX_REGIONS`]). + region_count: usize, + /// The region being read. + region: Option<(String, Region)>, + /// Depth of the `office:change-info` being read, and the field its text goes to. + info_at: Option, + info_field: Option, + /// Reading a deletion's content. + del: Option, + /// Insertions open in the body: (region id, revision). + active_ins: Vec<(String, RevMark)>, } impl Body<'_> { @@ -1123,7 +1450,31 @@ impl Body<'_> { fn end_para(&mut self) { let Some(mut p) = self.para.take() else { return }; self.fmt.pop(); - if p.kind != Kind::Code { + // A paragraph ending inside an open insertion has an inserted mark. (Paragraphs that + // `place_deletion` splits off keep only the marks read inside their region.) + if p.mark_ins.is_none() { + p.mark_ins = self.open_insertion().map(Box::new); + } + self.finish_para(p); + } + + /// The innermost insertion open where text is being read (the body, or deleted content). + fn open_insertion(&self) -> Option { + let open = match &self.del { + Some(d) => &d.ins, + None => &self.active_ins, + }; + open.last().map(|(_, m)| m.clone()) + } + + /// Add a finished paragraph to the current container. + fn finish_para(&mut self, mut p: Para) { + // Spaces next to tracked text are part of a change. + let tracked = self.del.is_some() + || p.mark_ins.is_some() + || p.mark_del.is_some() + || p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) if f.ins.is_some() || f.del.is_some())); + if p.kind != Kind::Code && !tracked { p.trim(); } if p.kind == Kind::Rule && !p.is_empty() { @@ -1156,7 +1507,8 @@ impl Body<'_> { } fn push_text(&mut self, t: &str) { - let f = self.cur_fmt(); + let mut f = self.cur_fmt(); + f.ins = self.open_insertion(); if self.para.is_none() { if t.trim().is_empty() { return; @@ -1172,6 +1524,17 @@ impl Body<'_> { if self.skip > 0 || !self.in_text { return; } + if self.info_at.is_some() { + if let (Some(field), Some((_, r))) = (self.info_field, &mut self.region) { + let s = if field { &mut r.author } else { &mut r.date }; + let room = MAX_INFO_CHARS.saturating_sub(s.chars().count()); + s.extend(t.chars().take(room)); + } + return; + } + if self.in_tc && self.del.is_none() { + return; + } if self.in_desc { if let Some(fr) = &mut self.frame { fr.3.push_str(t); @@ -1217,10 +1580,57 @@ impl Body<'_> { if !self.in_text { return; } + if self.info_at.is_some() { + self.info_field = match local(q) { + "creator" => Some(true), + "date" => Some(false), + _ => None, + }; + return; + } + if self.in_tc { + if self.del.is_none() { + self.tracked_start(q, a); + return; + } + // Inside deleted content: only insertions (text inserted, then deleted) nest. + if matches!(local(q), "change-start" | "change-end") { + let regions = &self.regions; + if let Some(d) = &mut self.del { + change_mark(regions, &mut d.ins, local(q) == "change-start", a); + } + return; + } + if matches!(local(q), "tracked-changes" | "changed-region" | "insertion" | "deletion" | "format-change" | "change") { + self.skip = 1; + return; + } + if local(q) == "change-info" && self.region.is_some() { + self.info_at = Some(self.depth); + return; + } + } match local(q) { + "tracked-changes" => { + if self.para.is_none() && self.tables.is_empty() && self.cells.is_empty() && self.frame.is_none() { + self.in_tc = true; + self.track = Some(get(a, "text:track-changes") != Some("false")); + } else { + self.skip = 1; + } + } + "change" => { + if let Some(id) = get(a, "text:change-id") + && self.regions.get(id).is_some_and(|r| r.kind == Some(RevisionKind::Delete)) + && let Some(r) = self.regions.remove(id) + { + let mark = r.mark(); + self.place_deletion(r.blocks, mark); + } + } + "change-start" | "change-end" => change_mark(&self.regions, &mut self.active_ins, local(q) == "change-start", a), "note" | "annotation" - | "tracked-changes" | "text-box" | "sequence-decls" | "forms" @@ -1355,9 +1765,141 @@ impl Body<'_> { } } + /// An element inside `text:tracked-changes`, outside deleted content. + fn tracked_start(&mut self, q: &str, a: &Attrs) { + match local(q) { + "changed-region" if self.region.is_none() && self.region_count < MAX_REGIONS => { + let id = get(a, "text:id").unwrap_or_default().to_string(); + let r = Region { id: self.region_count as u32, ..Default::default() }; + self.region_count += 1; + self.region = Some((id, r)); + } + "insertion" | "deletion" | "format-change" if self.region.as_ref().is_some_and(|(_, r)| r.kind.is_none()) => { + let kind = match local(q) { + "insertion" => RevisionKind::Insert, + "deletion" => RevisionKind::Delete, + _ => RevisionKind::Format, + }; + if let Some((_, r)) = &mut self.region { + r.kind = Some(kind); + } + if kind == RevisionKind::Delete { + self.containers.push(Vec::new()); + self.del = Some(DelState { + containers: self.containers.len(), + tables: self.tables.len(), + cells: self.cells.len(), + lists_saved: self.lists_saved.len(), + fmt: self.fmt.len(), + lists: std::mem::take(&mut self.lists), + item_used: std::mem::take(&mut self.item_used), + ins: Vec::new(), + }); + } + } + "change-info" if self.region.is_some() => self.info_at = Some(self.depth), + _ => self.skip = 1, + } + } + + /// End a deletion's content: its paragraphs go to the region being read. + fn close_deletion(&mut self) { + if self.del.is_none() { + return; + } + self.end_para(); + let Some(d) = self.del.take() else { return }; + let base = d.containers.saturating_sub(1).min(self.containers.len()); + let blocks: Vec = self.containers.split_off(base).into_iter().flatten().filter(|b| matches!(b, FBlock::Para(_))).collect(); + if self.containers.is_empty() { + self.containers.push(Vec::new()); + } + self.tables.truncate(d.tables); + self.cells.truncate(d.cells); + self.lists_saved.truncate(d.lists_saved); + self.fmt.truncate(d.fmt); + self.lists = d.lists; + self.item_used = d.item_used; + self.frame = None; + self.in_desc = false; + self.para_cont = false; + if let Some((_, r)) = &mut self.region { + r.blocks = blocks; + } + } + + /// Put a deletion's paragraphs back at a `text:change` point, as deleted text. Inside a + /// paragraph, each deleted paragraph mark splits it; between paragraphs, each deleted + /// paragraph stands alone with its mark deleted. + fn place_deletion(&mut self, blocks: Vec, mark: RevMark) { + let mut paras: Vec = Vec::new(); + for b in blocks { + let FBlock::Para(mut p) = b else { continue }; + // Pictures and other objects carry no revision mark: leave them out. + p.inlines.retain(|i| matches!(i, Inline::Text(..) | Inline::Anchor(_))); + for i in &mut p.inlines { + if let Inline::Text(_, f) = i { + f.del = Some(mark.clone()); + } + } + paras.push(p); + } + let append = |dst: &mut Para, src: Vec| { + for i in src { + match i { + Inline::Text(t, f) => dst.push_text(&t, &f), + other => dst.inlines.push(other), + } + } + }; + let Some(mut cur) = self.para.take() else { + self.para_cont = false; + for mut p in paras { + p.mark_del = Some(Box::new(mark.clone())); + self.finish_para(p); + } + return; + }; + let n = paras.len(); + let mut it = paras.into_iter(); + let mut first_mark_ins = None; + if let Some(first) = it.next() { + first_mark_ins = first.mark_ins; + append(&mut cur, first.inlines); + } + if n < 2 { + self.para = Some(cur); + return; + } + let mut rest = Para { kind: cur.kind, list: cur.list, align: cur.align, ..Default::default() }; + // The host paragraph now ends at the first deleted paragraph's mark. + cur.mark_del = Some(Box::new(mark.clone())); + cur.mark_ins = first_mark_ins; + self.finish_para(cur); + for (k, mut p) in it.enumerate() { + if k + 2 == n { + // The last deleted paragraph runs on into the rest of the body paragraph. + rest.kind = p.kind; + rest.align = p.align.or(rest.align); + append(&mut rest, p.inlines); + } else { + p.mark_del = Some(Box::new(mark.clone())); + self.finish_para(p); + } + } + self.para = Some(rest); + } + fn end(&mut self, q: &str) { self.depth = self.depth.saturating_sub(1); if q == "office:text" { + if self.del.is_some() { + // Unclosed deleted content (hostile): drop it. + self.close_deletion(); + } + self.in_tc = false; + self.region = None; + self.info_at = None; self.end_para(); self.in_text = false; return; @@ -1369,6 +1911,40 @@ impl Body<'_> { if !self.in_text { return; } + if let Some(d) = self.info_at { + if self.depth < d { + self.info_at = None; + } + self.info_field = None; + return; + } + if self.in_tc { + match local(q) { + "deletion" if self.del.is_some() => { + self.close_deletion(); + return; + } + "changed-region" => { + // A deletion left open (hostile) ends with its region. + self.close_deletion(); + if let Some((id, r)) = self.region.take() + && r.kind.is_some() + { + // The first region with an id wins. + self.regions.entry(id).or_insert(r); + } + return; + } + "tracked-changes" => { + self.close_deletion(); + self.in_tc = false; + self.region = None; + return; + } + _ if self.del.is_some() => {} + _ => return, + } + } match local(q) { "p" | "h" if !self.in_desc => self.end_para(), "span" | "a" if self.fmt.len() > usize::from(self.para.is_some()) => { @@ -1447,6 +2023,24 @@ impl Body<'_> { } } +/// A `text:change-start` (`start`) or `text:change-end` (attributes `a`) among the insertions +/// `open`: unknown ids, ends without a start and starts past [`MAX_OPEN_INSERTIONS`] are ignored. +fn change_mark(regions: &HashMap, open: &mut Vec<(String, RevMark)>, start: bool, a: &Attrs) { + let Some(id) = get(a, "text:change-id") else { return }; + if !start { + if let Some(k) = open.iter().position(|(o, _)| o == id) { + open.remove(k); + } + return; + } + if let Some(r) = regions.get(id).filter(|r| r.kind == Some(RevisionKind::Insert)) + && open.len() < MAX_OPEN_INSERTIONS + && !open.iter().any(|(o, _)| o == id) + { + open.push((id.to_string(), r.mark())); + } +} + fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Option> { let f = z.by_name(name).ok()?; if f.size() > MAX_ENTRY { @@ -1490,6 +2084,11 @@ pub fn parse(bytes: &[u8]) -> Result { /// [`parse`], with the last section's properties (the others are on the paragraphs that end them). fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { + parse_tracked(bytes).map(|(f, last, _)| (f, last)) +} + +/// [`parse_full`], plus whether change tracking is on (`None`: the document has no tracked changes). +fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option), String> { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?; if let Some(mt) = read_entry(&mut z, "mimetype") { let mt = String::from_utf8_lossy(&mt); @@ -1536,6 +2135,15 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { sec_from: 0, sections: 0, cols_at: None, + in_tc: false, + track: None, + regions: HashMap::new(), + region_count: 0, + region: None, + info_at: None, + info_field: None, + del: None, + active_ins: Vec::new(), }; events(&content, |ev| match ev { Ev::Start(q, a) => { @@ -1577,14 +2185,18 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { if blocks.is_empty() { blocks.push(FBlock::Para(Para::default())); } - Ok((Flow { blocks, meta }, last)) + let track = body.track; + Ok((Flow { blocks, meta }, last, track)) } /// Parse an `.odt` package into a document. pub fn import(bytes: &[u8]) -> Result { - let (flow, last) = parse_full(bytes)?; + let (flow, last, track) = parse_tracked(bytes)?; let mut doc = model::to_doc(&flow); doc.last_section = last; + if let Some(t) = track { + doc.settings.track_changes = t; + } Ok(doc) } @@ -1952,4 +2564,210 @@ mod tests { // A one-column page layout keeps the default column gap. assert_eq!(secs[0].1.columns, wordcraft_doc::section::Columns::default()); } + + // --- Tracked changes (ODF 1.3 Part 3 §5.5) ------------------------------------------------- + + const NS_TC: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#; + + /// An `.odt` whose body holds `regions` in `text:tracked-changes` and then `body`. + fn tracked(regions: &str, body: &str) -> Vec { + let content = format!( + r#"{regions}{body}"# + ); + package(&content, None) + } + + fn region(id: &str, kind: &str, author: &str, date: &str, content: &str) -> String { + format!( + r#"{author}{date}a remark on the change{content}"# + ) + } + + /// Each body paragraph as (text, revision kind) runs, plus its mark's revision kind. + type RevRuns = Vec<(Vec<(String, Option)>, Option)>; + + fn rev_runs(doc: &Document) -> RevRuns { + let kind = |i: Option| i.and_then(|i| doc.revisions.get(i as usize)).map(|r| r.kind); + doc.body + .iter() + .filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p) } else { None }) + .map(|p| { + let runs = p.run_ranges().map(|(r, cp)| (p.text[r].to_string(), kind(cp.del).or(kind(cp.ins)))).collect(); + (runs, kind(p.mark.del).or(kind(p.mark.ins))) + }) + .collect() + } + + fn insertion_and_deletion() -> Vec { + let regions = region("ct1", "insertion", "Alice", "2024-03-01T10:00:00", "") + + ®ion("ct2", "deletion", "Bob", "2024-03-02T11:30:00", "old"); + tracked( + ®ions, + r#"Keep new words."#, + ) + } + + #[test] + fn tracked_insertion_and_deletion_import_with_authors_and_dates() { + use wordcraft_doc::RevisionKind::{Delete, Insert}; + let doc = import(&insertion_and_deletion()).unwrap(); + let s = |v: &str| v.to_string(); + assert_eq!(rev_runs(&doc), vec![(vec![(s("Keep "), None), (s("new "), Some(Insert)), (s("old "), Some(Delete)), (s("words."), None)], None)]); + let revs: Vec<_> = doc.revisions.iter().map(|r| (r.kind, r.author.as_str(), r.date.as_str())).collect(); + assert_eq!(revs, vec![(Insert, "Alice", "2024-03-01T10:00:00"), (Delete, "Bob", "2024-03-02T11:30:00")]); + assert!(doc.settings.track_changes, "text:track-changes=\"true\" turns tracking on"); + } + + #[test] + fn tracked_deletion_across_paragraphs_keeps_the_deleted_paragraph_mark() { + use wordcraft_doc::RevisionKind::Delete; + // "First tail¶head rest." with "tail¶head " deleted: the body holds the merged paragraph. + let regions = region("d1", "deletion", "Bob", "2024-03-02T11:30:00", "tailhead"); + let doc = import(&tracked(®ions, r#"First rest."#)).unwrap(); + let s = |v: &str| v.to_string(); + assert_eq!( + rev_runs(&doc), + vec![(vec![(s("First "), None), (s("tail"), Some(Delete))], Some(Delete)), (vec![(s("head "), Some(Delete)), (s("rest."), None)], None),] + ); + } + + #[test] + fn tracked_changes_round_trip_through_export() { + let regions = region("ct1", "insertion", "Alice", "2024-03-01T10:00:00", "") + + ®ion("d1", "deletion", "Bob", "2024-03-02T11:30:00", "tailhead"); + let body = r#"First rest addedwhole newEnd."#; + let doc = import(&tracked(®ions, body)).unwrap(); + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(rev_runs(&back), rev_runs(&doc)); + let revs = |d: &Document| d.revisions.iter().map(|r| (r.kind, r.author.clone(), r.date.clone())).collect::>(); + assert_eq!(revs(&back), revs(&doc)); + assert_eq!(back.settings.track_changes, doc.settings.track_changes); + // The inserted paragraph mark survives: "added¶" is part of the insertion. + assert_eq!(rev_runs(&doc)[2].1, Some(wordcraft_doc::RevisionKind::Insert)); + } + + #[test] + fn hostile_tracked_changes_are_bounded() { + // Unknown and duplicate ids, a change-end without a start, nested regions, a deletion + // inside a deletion, a change mark inside the deleted text and tracked changes inside a paragraph. + let regions = region("d1", "deletion", "B", "x", r#"gone"#) + + r#"inner"# + + ®ion("dd", "deletion", "B", "x", "deeperok") + + ®ion("d1", "insertion", "C", "y", ""); + let body = r#"abc"#; + let doc = import(&tracked(®ions, body)).unwrap(); + let text: String = rev_runs(&doc).iter().flat_map(|(r, _)| r.iter().map(|(t, _)| t.clone())).collect(); + assert_eq!(text.matches("gone").count(), 1, "a deletion is placed once: {text}"); + assert!(!text.contains("deeper"), "{text}"); + assert!(text.starts_with('a') && text.ends_with('c'), "{text}"); + + // A huge number of regions is capped. + let many: String = (0..20_000).map(|i| format!(r#""#)).collect(); + let starts: String = (0..20_000).map(|i| format!(r#"x"#)).collect(); + let doc = import(&tracked(&many, &format!("{starts}"))).unwrap(); + assert!(doc.revisions.len() <= 10_000, "{}", doc.revisions.len()); + } + + /// Every paragraph (body and table cells, in order) as (text, inserted, deleted) runs plus + /// its mark's (inserted, deleted). + type Marks = Vec<(Vec<(String, bool, bool)>, (bool, bool))>; + + fn marks(doc: &Document) -> Marks { + fn walk(bl: &wordcraft_doc::Blocks, out: &mut Marks) { + for b in bl { + match &**b { + wordcraft_doc::Block::Para(p) => out.push(( + p.run_ranges().map(|(r, cp)| (p.text[r].to_string(), cp.ins.is_some(), cp.del.is_some())).collect(), + (p.mark.ins.is_some(), p.mark.del.is_some()), + )), + wordcraft_doc::Block::Table(t) => { + for r in &t.rows { + for c in &r.cells { + walk(&c.blocks, out); + } + } + } + } + } + } + let mut out = Vec::new(); + walk(&doc.body, &mut out); + out + } + + #[test] + fn inserted_then_deleted_text_keeps_both_marks() { + // "new" was inserted by Alice, then Bob deleted "old new": the deletion's content holds + // the insertion as a change range. + let regions = region("i1", "insertion", "Alice", "2024-03-01T10:00:00", "") + + ®ion( + "d1", + "deletion", + "Bob", + "2024-03-02T11:30:00", + r#"old new"#, + ); + let doc = import(&tracked(®ions, r#"Keep end."#)).unwrap(); + let s = |v: &str| v.to_string(); + let want = + vec![(vec![(s("Keep "), false, false), (s("old "), false, true), (s("new"), true, true), (s("end."), false, false)], (false, false))]; + assert_eq!(marks(&doc), want); + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(marks(&back), want, "both marks survive a save"); + assert_eq!(back.revisions.len(), doc.revisions.len(), "one deletion stays one region and one revision: {:?}", back.revisions); + } + + #[test] + fn a_deleted_mark_inside_an_insertion_is_not_inserted() { + // "A" inserted by Alice, then a paragraph break Bob deleted, then "C" inserted by Alice: + // the insertion range runs across the deletion point, but the deleted mark wasn't inserted. + let regions = region("i1", "insertion", "Alice", "2024-03-01T10:00:00", "") + + ®ion("d1", "deletion", "Bob", "2024-03-02T11:30:00", ""); + let body = + r#"AC"#; + let doc = import(&tracked(®ions, body)).unwrap(); + let s = |v: &str| v.to_string(); + let want = vec![(vec![(s("A"), true, false)], (false, true)), (vec![(s("C"), true, false)], (false, false))]; + assert_eq!(marks(&doc), want); + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(marks(&back), want, "after a save"); + } + + #[test] + fn insertions_end_before_a_table_and_inside_a_cell() { + let regions = region("i1", "insertion", "Alice", "2024-03-01T10:00:00", "") + ®ion("i2", "insertion", "Bob", "2024-03-02T10:00:00", ""); + let table = r#"Cell added"#; + let body = format!( + r#"StartNew paragraph{table}End."# + ); + let content = format!( + r#"{regions}{body}"# + ); + let doc = import(&package(&content, None)).unwrap(); + let s = |v: &str| v.to_string(); + let want = vec![ + (vec![(s("Start"), false, false)], (false, false)), + (vec![(s("New paragraph"), true, false)], (true, false)), + (vec![(s("Cell "), false, false), (s("added"), true, false)], (true, false)), + (vec![(s("End."), false, false)], (false, false)), + ]; + assert_eq!(marks(&doc), want); + let back = import(&export(&doc).unwrap()).unwrap(); + assert_eq!(marks(&back), want); + let authors = |d: &Document| d.revisions.iter().map(|r| r.author.clone()).collect::>(); + assert_eq!(authors(&back), authors(&doc)); + } + + #[test] + fn exported_changes_always_have_a_date_and_keep_paragraph_separators() { + // A change without a date gets one on save (`dc:date` is required); a U+2029 in deleted + // text is text, not a paragraph break. + let regions = r#"Boba
b"#; + let doc = import(&tracked(regions, r#"xy"#)).unwrap(); + assert_eq!(doc.revisions.first().map(|r| r.date.as_str()), Some("")); + let back = import(&export(&doc).unwrap()).unwrap(); + assert!(back.revisions.first().is_some_and(|r| r.date.len() >= 19 && r.date.starts_with("20")), "{:?}", back.revisions); + assert_eq!(marks(&back), marks(&doc)); + assert_eq!(marks(&back).len(), 1); + } } diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index d5ac7db4f..53dc89e04 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -1066,7 +1066,7 @@ impl Reader { /// the table is "Unknown". fn rev_mark(&self, i: i32, dttm: u32) -> model::RevMark { let author = usize::try_from(i).ok().and_then(|i| self.rev_authors.get(i)).cloned().unwrap_or_else(|| "Unknown".to_string()); - model::RevMark { author, date: dttm_to_iso(dttm).unwrap_or_default() } + model::RevMark { author, date: dttm_to_iso(dttm).unwrap_or_default(), id: None } } fn commit_font(&mut self) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5272480c1..ba5d774b4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: RTF tracked insertions and deletions read and written, gap 19; previously trivial: ODT sections on import, #1379; previously trivial: PDF export: Hebrew and Arabic text extracts as whole words, #1383; previously trivial: HTML header cells bold, cell `text-align` kept, and HTML/Markdown/text open on the region's paper, #1381; previously trivial: `.doc` table cell shading and the extra column, #1380; previously trivial: `.doc` tables from Word 2000+: cell shading, no extra column, merged rows, #1380; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: ODT tracked changes read and written, gap 19; previously trivial: RTF tracked insertions and deletions read and written, gap 19; 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; @@ -26,7 +26,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | 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 or encrypted files are dropped or refused. No writer | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379). No footnotes, comments, tracked changes, fields or RTL | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | @@ -170,6 +170,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | | 2026-10-13 | trivial | HTML export writes equations as MathML instead of linear text, and HTML import (open and paste) reads `` back into equations; a copy holding an equation puts that HTML on the system clipboard: MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816) | | 2026-10-14 | trivial | PDF export: All Caps and Small Caps text extracts in capitals, as displayed, and no longer turns later capitals in uncapitalised text lowercase (#1473) | +| 2026-10-14 | trivial | ODT tracked changes: `text:tracked-changes` insertions and deletions open as revisions (author, date, inserted and deleted paragraph marks, insertions nested in deletions) and are written back, a missing date as the save time; `text:track-changes` maps to Track Changes on/off. Pictures, other objects and tables inside a tracked deletion are dropped | | 2026-10-14 | trivial | RTF reads and writes tracked insertions and deletions with their authors and dates (gap 19); deleted text no longer opens as normal text | | 2026-10-14 | trivial | ODT import reads sections: a paragraph naming another master page starts a new-page section with that page's size, orientation, margins and columns, and its `style:page-number` restart; a columned `text:section` is a continuous section with its columns (#1379) | | 2026-10-14 | trivial | PDF export writes right-to-left runs' glyphs in visual order, so Chrome, Edge (PDFium) and poppler extract Hebrew and Arabic as whole words (vowelled words keep their letters in order but may extract with spaces inside), and Ctrl+F finds them (#1383). `/ReversedChars` (for strict file-order readers) covers plain runs and vowelled runs whose marks split into their own glyphs, not runs with ligatures or mixed directions | diff --git a/docs/gaps.md b/docs/gaps.md index fe2ed20f6..be055dfa3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (RTF tracked insertions and deletions, gap 19; previously trivial: Ink to Shape recognises arrows; 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-14 · **Change:** trivial (ODT tracked insertions and deletions, gap 19; previously trivial: RTF tracked insertions and deletions, gap 19; 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. @@ -33,7 +33,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF insertions and deletions done), sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | @@ -84,6 +84,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word | | 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | | 2026-10-12 | trivial | #2 narrowed: direct run-level `w:contentPart` InkML survives DOCX edits and repeated saves; nested group/canvas ink and native rendering remain | +| 2026-10-14 | trivial | Gap 19: ODT tracked insertions and deletions (with paragraph marks, and text inserted then deleted) read and written | | 2026-10-14 | trivial | Gap 19: RTF tracked insertions and deletions read and written; RTF paragraph-mark and formatting revisions, and ODT revisions, remain | | 2026-10-14 | trivial | #19 narrowed: ODT import keeps sections (master-page changes, columned `text:section`s, page-numbering restarts) (#1379) | | 2026-10-14 | trivial | #16: PDF export writes right-to-left glyphs in visual order, so Hebrew and Arabic extract, copy and search as whole words (#1383) | From 0a2b429b05ac2bbb10508a1880082b023bc0172c Mon Sep 17 00:00:00 2001 From: Eduardo Garcia Date: Wed, 14 Oct 2026 15:45:18 -0300 Subject: [PATCH 089/199] fix(pdf): inline pictures keep their alt text in tagged PDF (#1502) (#1526) * fix(pdf): inline pictures keep their alt text in tagged PDF (#1502) Inline pictures were tagged as Figure with no alt, so the exporter fell back to "picture" and screen readers announced every inline image that way, while floating pictures kept their alt text. Draw::Image now carries the picture's alt text from the InlineObject, and the Placed::Lines branch of the PDF exporter passes it to the Figure tag, including for rotated inline pictures. A picture without alt text still falls back to "picture". Adds a regression test in crates/pdf/src/tests.rs. Co-Authored-By: Claude Sonnet 5.5 * rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/display.rs | 73 +++++++++--------------------------- crates/pdf/src/lib.rs | 7 ++-- crates/pdf/src/tests.rs | 19 ++++++++++ crates/render/src/lib.rs | 2 +- 4 files changed, 42 insertions(+), 59 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index c83ab7613..ff0bed885 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -68,6 +68,8 @@ pub enum Draw { media: String, crop: [f32; 4], alpha: f32, + /// The picture's alt text (empty for a floating or diagram picture, which tag themselves). + alt: String, }, Shape { rect: Rect, @@ -82,73 +84,30 @@ pub enum Draw { }, /// An ink stroke: a line through `pts` (page points) with round ends and joins, `width` /// wide, in `color` at opacity `alpha`. - Ink { - pts: Vec<(f32, f32)>, - color: Rgb, - width: f32, - alpha: f32, - }, + Ink { pts: Vec<(f32, f32)>, color: Rgb, width: f32, alpha: f32 }, /// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked. - Path { - segs: Vec, - fill: Option, - stroke: Option, - stroke_width: f32, - }, + Path { segs: Vec, fill: Option, stroke: Option, stroke_width: f32 }, /// An inline chart or diagram's draws, as one figure with its alt text (for tagged PDF). - Figure { - alt: String, - kind: GraphicKind, - draws: Vec, - }, + Figure { alt: String, kind: GraphicKind, draws: Vec }, /// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked /// paragraph mark in its reviser's colour). - Mark { - x: f32, - baseline: f32, - size: f32, - ch: char, - color: Option, - }, + Mark { x: f32, baseline: f32, size: f32, ch: char, color: Option }, /// Turned text (a table cell's text direction): `items` are drawn in a frame turned `turn` /// whose origin is page point (`x`, `y`) (see [`crate::turn_point`]). - Turned { - x: f32, - y: f32, - turn: TextDirection, - items: Vec, - }, + Turned { x: f32, y: f32, turn: TextDirection, items: Vec }, /// A rotated or flipped picture, shape, chart, group or Drawing Canvas: `items` drawn /// turned by `spin` about page point (`cx`, `cy`) (see [`Spin::matrix`]). - Rotated { - cx: f32, - cy: f32, - spin: Spin, - items: Vec, - }, + Rotated { cx: f32, cy: f32, spin: Spin, items: Vec }, /// `items` clipped to `rect` (a Drawing Canvas's members to its frame). Inside a /// [`Draw::Rotated`], `rect` is in the turned frame's (unturned) coordinates. - Clip { - rect: Rect, - items: Vec, - }, + Clip { rect: Rect, items: Vec }, /// Text drawn from its outlines with WordArt effects (and bent, in a warped text box): /// `path` in page coordinates, painted as [`crate::wordart::art_layers`] says. `color` is the /// run's own colour, the fill when the effects set none. - Art { - path: BezPath, - fx: Box, - color: Rgb, - alpha: f32, - }, + Art { path: BezPath, fx: Box, color: Rgb, alpha: f32 }, /// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at /// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark. - MarkText { - x: f32, - baseline: f32, - size: f32, - text: String, - }, + MarkText { x: f32, baseline: f32, size: f32, text: String }, } impl Draw { @@ -577,7 +536,7 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }), Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)), Placed::Image { rect, media, crop, spin, .. } => { - spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }], out) + spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha, alt: String::new() }], out) } Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin, extra } => { let mut items = Vec::new(); @@ -1276,7 +1235,9 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec out.extend(text_glyphs(doc, inside(r), it, alpha, k)), - GraphicItem::Image { rect: r, media } => out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha }), + GraphicItem::Image { rect: r, media } => { + out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha, alt: String::new() }) + } } } out @@ -1521,7 +1482,9 @@ fn spun(spin: Spin, rect: Rect, items: Vec, out: &mut Vec) { fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &DisplayOptions, out: &mut Vec) { let own = o.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(); match o { - InlineObject::Image { media, crop, .. } => spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha }], out), + InlineObject::Image { media, crop, alt, .. } => { + spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha, alt: alt.clone() }], out) + } InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => { let effects = effects_in(*effects, own.within(outer)); let mut items = Vec::new(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 2a36110ae..00e9c392d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -784,10 +784,11 @@ impl Exporter<'_> { for d in &draws { match d { Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), - Draw::Image { .. } => self.tagged(s, Role::Figure, None, |me, s| me.draw(s, d)), + Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), // A rotated inline picture. Draw::Rotated { items, .. } if items.iter().any(|i| matches!(i, Draw::Image { .. })) => { - self.tagged(s, Role::Figure, None, |me, s| me.draw(s, d)) + let alt = items.iter().find_map(|i| if let Draw::Image { alt, .. } = i { Some(alt.as_str()) } else { None }); + self.tagged(s, Role::Figure, alt, |me, s| me.draw(s, d)) } // An inline chart or diagram: its text is part of the figure, not the paragraph. Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(graphic_alt(alt, *kind)), |me, s| { @@ -899,7 +900,7 @@ impl Exporter<'_> { } } Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha, .. } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha), - Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha), + Draw::Image { rect, media, crop, alpha, .. } => self.picture(s, rect, media, crop, *alpha), Draw::Path { segs, fill: f, stroke, stroke_width } => { let mut pb = PathBuilder::new(); append_path(&mut pb, &wordcraft_layout::display::seg_path(segs)); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2b434239a..5899ae51b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -816,6 +816,25 @@ fn metafile_pictures_are_drawn_as_vector_paths() { assert!(!raw.contains("0.8156"), "the grey placeholder box is not drawn"); } +/// #1502: an inline picture's Figure carries its own alt text, not the "picture" fallback. +#[test] +fn inline_picture_figure_keeps_its_alt_text() { + let raw = |alt: &str| { + let mut d = Document::new(); + let media = d.add_media(red_right_half_emf(), "emf"); + let mut p = Paragraph::with_text("Figure: ", CharProps::default()); + let n = p.len(); + let obj = InlineObject::Image { media, w: 70.0, h: 70.0, alt: alt.into(), float: Default::default(), crop: [0.0; 4], ole: None }; + p.insert_object(n, obj, &CharProps::default()).unwrap(); + d.body = vec![para_block(p)]; + String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned() + }; + let with = raw("Blue rectangle used as test image"); + assert!(with.contains("/Alt(Blue rectangle used as test image)"), "inline alt text lost"); + assert!(!with.contains("/Alt(picture)")); + assert!(raw("").contains("/Alt(picture)"), "no alt text keeps the fallback"); +} + /// An EMF file of the given records, with a 25 mm frame and 1000 device pixels across (0.04 per 0.01 mm). fn emf_file(records: &[Vec]) -> Vec { let le32 = |vals: &[i32]| -> Vec { vals.iter().flat_map(|v| v.to_le_bytes()).collect() }; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index abf4e29df..75805b0d9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -767,7 +767,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl gx += face.advance(gid) * k; } } - Draw::Image { rect, media, crop, alpha } => { + Draw::Image { rect, media, crop, alpha, .. } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); if !r.overlaps(*visible) { return; From 3fc4ef2cf43c2b3d99af6b8abad66508b36408ed Mon Sep 17 00:00:00 2001 From: Ufuk Efe Date: Wed, 14 Oct 2026 21:45:30 +0300 Subject: [PATCH 090/199] proof: Turkish spelling, suggestions, grammar and hyphenation (#1527) * proof: Turkish spelling, suggestions, grammar and hyphenation * Turkish: dictionary tests skip when no Turkish Hunspell dictionary is installed Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/wordcraft/src/control_server.rs | 11 +- crates/engine/src/tests.rs | 26 ++ crates/proof/src/hunspell.rs | 10 + crates/proof/src/hyphen.rs | 3 + crates/proof/src/lang.rs | 19 +- crates/proof/src/lib.rs | 23 +- crates/proof/src/tr/dict.rs | 129 ++++++++++ crates/proof/src/tr/grammar.rs | 352 +++++++++++++++++++++++++++ crates/proof/src/tr/hyphen.rs | 98 ++++++++ crates/proof/src/tr/mod.rs | 156 ++++++++++++ crates/proof/src/tr/suggest.rs | 298 +++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 9 +- crates/ui-egui/src/i18n/tests.rs | 2 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 14 +- docs/gaps.md | 2 +- docs/localization-parity.md | 1 + docs/localization-tr.md | 6 +- 18 files changed, 1144 insertions(+), 16 deletions(-) create mode 100644 crates/proof/src/tr/dict.rs create mode 100644 crates/proof/src/tr/grammar.rs create mode 100644 crates/proof/src/tr/hyphen.rs create mode 100644 crates/proof/src/tr/mod.rs create mode 100644 crates/proof/src/tr/suggest.rs diff --git a/apps/wordcraft/src/control_server.rs b/apps/wordcraft/src/control_server.rs index 54828d5bd..2b8f4137d 100644 --- a/apps/wordcraft/src/control_server.rs +++ b/apps/wordcraft/src/control_server.rs @@ -484,7 +484,16 @@ mod tests { assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none(), "no key file after a failed bind"); drop(taken); // Bound: the key file holds the key that opens the port. - let (rx, key_file) = start(port, egui::Context::default(), Some(&dir)).unwrap(); + let mut free_port = port.wrapping_add(1).max(1024); + let mut started = None; + for _ in 0..100 { + if let Some(res) = start(free_port, egui::Context::default(), Some(&dir)) { + started = Some((res, free_port)); + break; + } + free_port = free_port.wrapping_add(1).max(1024); + } + let ((rx, key_file), port) = started.unwrap(); let key_file = key_file.unwrap(); assert_eq!(key_file.path(), wordcraft_control_key::key_path(&dir, port)); let key = std::fs::read_to_string(key_file.path()).unwrap(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 136d52cdb..7ac771e4d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4804,6 +4804,32 @@ fn polish_proofing_through_the_review_commands() { assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); } +/// Review › Language and proofing commands work in Turkish end to end. +#[test] +fn turkish_proofing_through_the_review_commands() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Bana bazen gelenler oldu ise de. Sana olacaklardan sonra sorna"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "tr-TR", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("tr-TR")); + + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + // "sorna" must be flagged as spelling issue + let sorna_issue = arr.iter().find(|i| i["text"] == "sorna" && i["kind"] == "spelling"); + assert!(sorna_issue.is_some(), "sorna must be flagged: {issues}"); + + // Check suggestions for sorna + let sugs: Vec = sorna_issue.unwrap()["suggestions"].as_array().unwrap().iter().filter_map(|x| x.as_str().map(str::to_string)).collect(); + assert!(sugs.contains(&"sonra".to_string()), "sorna must suggest sonra: {sugs:?}"); + + // Add to Dictionary: adding "sorna" should remove its issue + run(&mut s, "review.addToDictionary", json!({"word": "sorna"})); + let issues_after = run(&mut s, "review.issues", json!({})); + let arr_after = issues_after.as_array().unwrap(); + assert!(!arr_after.iter().any(|i| i["text"] == "sorna"), "sorna should no longer be flagged after adding to dictionary: {issues_after}"); +} + /// `review.language`: tags are validated and normalized, `noProof` alone keeps the language, /// no parameters keep the old meaning (US English), and `default` needs a language. #[test] diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index 113420843..c596d4607 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -66,6 +66,16 @@ pub fn search_dirs(lo_exts: Option<&[String]>) -> Vec { dirs.extend(lo_dirs(&base.join("LibreOffice").join("share").join("extensions").to_string_lossy())); } } + // If LibreOffice is installed on another drive (e.g. D:\Program Files\LibreOffice), check drives C..Z + for drive in b'C'..=b'Z' { + let drive_char = drive as char; + for pf in ["Program Files", "Program Files (x86)"] { + let lo_base = PathBuf::from(format!(r"{drive_char}:\{pf}\LibreOffice\share\extensions")); + if lo_base.is_dir() { + dirs.extend(lo_dirs(&lo_base.to_string_lossy())); + } + } + } } else if cfg!(target_os = "macos") { if let Some(h) = &home { dirs.push(h.join("Library/Spelling")); diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index c8a17d32f..913017b6d 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -127,6 +127,9 @@ fn core_breaks(core: &[char], lang: ProofLang) -> (Vec, Source) { if lang == ProofLang::Bg && core.iter().all(|&c| crate::bg::is_letter(c)) { return (crate::bg::points(core), Source::Rules); } + if lang == ProofLang::Tr && core.iter().all(|&c| crate::tr::hyphen::is_letter(c)) { + return (crate::tr::hyphen::points(core), Source::Rules); + } if lang == ProofLang::Pt { return core_breaks_pt(core); } diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index 48ef51aa8..62075c9ed 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -26,6 +26,9 @@ pub enum ProofLang { It, /// Croatian (`hr`, `hr-HR`) Hr, + /// Turkish (`tr`, `tr-TR`, `tr-CY`): the installed Hunspell dictionary (when there is one), + /// Turkish grammar rules, spelling suggestions and Turkish syllable hyphenation rules. + Tr, /// A language without proofing tools here (`fr-FR`, `ja-JP`, `x-none`…). Like Word without /// the language's proofing tools: no spelling or grammar marks, so its words are never /// flagged as English misspellings. Hyphenation keeps using the English patterns, as before @@ -35,7 +38,7 @@ pub enum ProofLang { impl ProofLang { /// Every language with a spelling dictionary, in display order. - pub const PROOFED: [ProofLang; 5] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr]; + pub const PROOFED: [ProofLang; 6] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr]; /// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). Only the primary subtag /// counts. No tag (or an empty one) is English, WordCraft's default editing language. @@ -54,12 +57,14 @@ impl ProofLang { ProofLang::It } else if primary.eq_ignore_ascii_case("hr") || primary.eq_ignore_ascii_case("hrv") { ProofLang::Hr + } else if primary.eq_ignore_ascii_case("tr") || primary.eq_ignore_ascii_case("tur") { + ProofLang::Tr } else { ProofLang::Other } } - /// The primary language subtag (`en`, `pl`, `bg`); empty for [`ProofLang::Other`]. + /// The primary language subtag (`en`, `pl`, `bg`, `tr`); empty for [`ProofLang::Other`]. pub fn code(self) -> &'static str { match self { ProofLang::En => "en", @@ -68,6 +73,7 @@ impl ProofLang { ProofLang::Pt => "pt", ProofLang::It => "it", ProofLang::Hr => "hr", + ProofLang::Tr => "tr", ProofLang::Other => "", } } @@ -153,7 +159,10 @@ mod tests { for t in ["bg", "bg-BG", "BG_bg", "bg_BG.UTF-8", "bul"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Bg, "{t}"); } - for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "e", "-"] { + for t in ["tr", "tr-TR", "tr-CY", "TR_tr", "tur"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Tr, "{t}"); + } + for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "trx", "e", "-"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}"); } assert_eq!(ProofLang::from_tag(None), ProofLang::En); @@ -164,10 +173,14 @@ mod tests { assert!(!ProofLang::Bg.is_proofed()); assert_eq!(ProofLang::Bg.code(), "bg"); assert!(ProofLang::Pt.is_proofed() && !ProofLang::Other.is_proofed()); + assert!(ProofLang::Tr.is_proofed()); assert_eq!(ProofLang::Pl.code(), "pl"); assert_eq!(ProofLang::Pt.code(), "pt"); assert!(ProofLang::It.is_proofed()); assert_eq!(ProofLang::It.code(), "it"); + assert!(ProofLang::Hr.is_proofed()); + assert_eq!(ProofLang::Hr.code(), "hr"); + assert_eq!(ProofLang::Tr.code(), "tr"); assert!(ProofLang::PROOFED.contains(&ProofLang::Pt), "every proofed language is listed"); } } diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 6b34a4a5d..d644b62f9 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -42,6 +42,7 @@ pub mod lang; pub mod patterns; pub mod pl; pub mod pt; +pub mod tr; use std::collections::HashSet; use std::ops::Range; @@ -129,9 +130,10 @@ pub fn dictionary_generation() -> u64 { DICTIONARY_GENERATION.load(Ordering::Relaxed) } -/// Is `lower` (a lower-case word) in the user dictionary? +/// Is `lower` (a lower-case word) in the user dictionary or ignored? pub(crate) fn in_user_dictionary(lower: &str) -> bool { user_words().read().unwrap_or_else(|e| e.into_inner()).contains(lower) + || ignored_words().read().unwrap_or_else(|e| e.into_inner()).contains(lower) } /// Words in the user dictionary. @@ -533,6 +535,7 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option ProofLang::Hr => "Moguća pravopisna pogreška", ProofLang::Pt => "Possível erro de ortografia", ProofLang::It => "Possibile errore di ortografia", + ProofLang::Tr => "Olası yazım hatası", _ => "Possible spelling mistake", }; Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang }) @@ -551,6 +554,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { ProofLang::Hr => accepted_by_options(word, opts) || hr::is_correct(word), ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word), ProofLang::It => accepted_by_options(word, opts) || it::is_correct(word), + ProofLang::Tr => accepted_by_options(word, opts) || tr::is_correct(word), ProofLang::Bg | ProofLang::Other => true, } } @@ -563,13 +567,14 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { ProofLang::Hr => hr::suggest(word, max), ProofLang::Pt => pt::suggest(word, max), ProofLang::It => it::suggest(word, max), + ProofLang::Tr => tr::suggest(word, max), ProofLang::Bg | ProofLang::Other => Vec::new(), } } /// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones /// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), the Italian ones ([`it::grammar`]), -/// or none for a language WordCraft doesn't proof. +/// the Turkish ones ([`tr::grammar`]), or none for a language WordCraft doesn't proof. pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { match lang { ProofLang::En => check_grammar(text), @@ -577,6 +582,7 @@ pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { ProofLang::Hr => hr::grammar::check(text), ProofLang::Pt => pt::grammar::check(text), ProofLang::It => it::grammar::check(text), + ProofLang::Tr => tr::grammar::check(text), ProofLang::Bg | ProofLang::Other => Vec::new(), } } @@ -1162,7 +1168,7 @@ mod tests { let _ = has_dictionary(Some(&s)); let _ = is_correct_for(&s, Some(&s)); let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default()); - for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Other] { + for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Other] { let _ = check_spelling_in(&s, lang); let _ = check_grammar_in(&s, lang); let _ = is_correct_in(&s, lang); @@ -1183,9 +1189,14 @@ mod tests { } #[test] - fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..4), 0..6)) { - let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::Other]; - let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % 4])).collect(); + fn turkish_suggestions_never_panic(s in "[a-zçğıöşüA-ZÇĞİÖŞÜ'’ -]{0,14}") { + let _ = suggest_in(&s, 4, ProofLang::Tr); + } + + #[test] + fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..5), 0..6)) { + let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::Tr, ProofLang::Other]; + let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % 5])).collect(); for i in check_text(&s, &spans) { proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); } diff --git a/crates/proof/src/tr/dict.rs b/crates/proof/src/tr/dict.rs new file mode 100644 index 000000000..ed57cbca7 --- /dev/null +++ b/crates/proof/src/tr/dict.rs @@ -0,0 +1,129 @@ +//! Turkish Hunspell dictionary parser with `FLAG num` support. +//! +//! Turkish uses `FLAG num`, storing thousands of numerical suffix flags (0..6465). +//! This engine parses `/usr/share/hunspell/tr_TR.aff` and `.dic` safely without panics. + +use std::collections::HashMap; + +/// A suffix rule for Turkish. +#[derive(Clone, Debug)] +pub struct TrSuffixRule { + pub strip: Box, + pub add: Box, + pub cond: Box, +} + +/// Dedicated Turkish Hunspell dictionary engine. +#[derive(Clone, Debug, Default)] +pub struct TrDict { + /// Stem -> list of numerical flags it carries. + stems: HashMap>, + /// Flag number -> list of suffix rules. + rules: HashMap>, + /// Suffix 'add' string -> list of (flag, rule_index). + suffix_index: HashMap>, + /// Longest suffix 'add' string in characters. + max_suffix_len: usize, +} + +impl TrDict { + /// Returns true if empty (no dictionary loaded). + pub fn is_empty(&self) -> bool { + self.stems.is_empty() + } + + /// Load from `.aff` and `.dic` contents. + pub fn from_hunspell(aff: &str, dic: &str) -> Result { + let mut rules: HashMap> = HashMap::new(); + let mut max_suffix_len = 0; + + for line in aff.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + // SFX [] + if parts.len() >= 4 && parts[0] == "SFX" { + let Ok(flag) = parts[1].parse::() else { continue }; + // Skip header line: `SFX Y/N ` + if parts.len() == 4 && (parts[2] == "Y" || parts[2] == "N") && parts[3].parse::().is_ok() { + continue; + } + let strip = if parts[2] == "0" { "" } else { parts[2] }; + let add = if parts[3] == "0" { "" } else { parts[3] }; + let cond = if parts.len() > 4 { parts[4] } else { "." }; + + max_suffix_len = max_suffix_len.max(add.chars().count()); + + rules.entry(flag).or_default().push(TrSuffixRule { strip: strip.into(), add: add.into(), cond: cond.into() }); + } + } + + let mut suffix_index: HashMap> = HashMap::new(); + for (&flag, r_list) in &rules { + for (idx, r) in r_list.iter().enumerate() { + if let Ok(idx_u16) = u16::try_from(idx) { + suffix_index.entry(r.add.to_string()).or_default().push((flag, idx_u16)); + } + } + } + + let mut stems: HashMap> = HashMap::new(); + for (i, line) in dic.lines().enumerate() { + if i == 0 && line.trim().parse::().is_ok() { + continue; + } + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some((word, flags_part)) = line.split_once('/') { + let flags: Vec = flags_part.split(',').filter_map(|f| f.parse::().ok()).collect(); + stems.insert(word.to_string(), flags.into_boxed_slice()); + } else { + stems.insert(line.to_string(), Box::new([])); + } + } + + Ok(TrDict { stems, rules, suffix_index, max_suffix_len }) + } + + /// Check if word is correct (stem or affixed). + pub fn contains(&self, word: &str) -> bool { + if self.is_empty() || word.is_empty() { + return false; + } + + // Direct stem hit + if self.stems.contains_key(word) { + return true; + } + + // Try stripping suffixes matching the tail + let word_len = word.chars().count(); + let char_indices: Vec<(usize, char)> = word.char_indices().collect(); + + // Check each possible suffix length up to max_suffix_len + for cut_chars in 0..=self.max_suffix_len.min(word_len) { + let byte_idx = if cut_chars == 0 { word.len() } else { char_indices[word_len - cut_chars].0 }; + + let add = &word[byte_idx..]; + let base = &word[..byte_idx]; + + if let Some(matches) = self.suffix_index.get(add) { + for &(flag, rule_idx) in matches { + let Some(rule_list) = self.rules.get(&flag) else { continue }; + let Some(rule) = rule_list.get(rule_idx as usize) else { continue }; + + // Check condition on root + let mut candidate = String::with_capacity(base.len() + rule.strip.len()); + candidate.push_str(base); + candidate.push_str(&rule.strip); + + if self.stems.get(&candidate).is_some_and(|f_list| f_list.contains(&flag)) { + return true; + } + } + } + } + + false + } +} diff --git a/crates/proof/src/tr/grammar.rs b/crates/proof/src/tr/grammar.rs new file mode 100644 index 000000000..f0598a17d --- /dev/null +++ b/crates/proof/src/tr/grammar.rs @@ -0,0 +1,352 @@ +//! Turkish grammar and punctuation checks: cheap, rule-based and tuned to stay quiet when unsure. +//! Messages are in Turkish. +//! +//! - **Repeated word** (`çok çok`), except idiomatic reduplications and doublings (`ALLOWED_REPEATS`: +//! `yavaş yavaş`, `tıkır tıkır`, `adım adım`). +//! - **Space before punctuation** (`ev ,`) and **double spaces**. +//! - **Capital letter at the start of a sentence**, knowing Turkish abbreviations (`vb.`, `vs.`, +//! `prof.`, `dr.`, `mad.`, `yy.`, `s.`…) after which lowercase or numbers may continue. +//! - **No comma before/after conjunctions**: `ve`, `veya`, `yahut`, `ile`, `de`, `da`, `hem ... hem`, +//! `ne ... ne` conjunctions should not have commas immediately before or after them. +//! - **Doubled punctuation** (`..`, `,,`, `;;`). + +use crate::{Issue, IssueKind, ProofLang, words}; + +/// Turkish reduplications and emphatic word pairings that are intentionally repeated. +const ALLOWED_REPEATS: &[&str] = &[ + "yavaş", + "hızlı", + "koşa", + "güle", + "adım", + "kara", + "tıkır", + "şakır", + "akın", + "ağır", + "açık", + "ayrı", + "bire", + "bir", + "boş", + "bölük", + "buram", + "cana", + "can", + "cılk", + "çifter", + "çit", + "dalga", + "damla", + "deste", + "didik", + "dizi", + "dolu", + "deste", + "döne", + "eğri", + "enine", + "epey", + "fıkır", + "fırıl", + "gece", + "gıcır", + "gide", + "günü", + "güzel", + "halk", + "harıl", + "hık", + "hım", + "horul", + "ılık", + "ışıl", + "için", + "içten", + "iğne", + "iri", + "iyiden", + "iyi", + "kaba", + "kaç", + "kat", + "kaynar", + "kelli", + "kese", + "kıtır", + "kıvrım", + "koca", + "kol", + "kucak", + "kütür", + "mırıl", + "öbek", + "parça", + "patır", + "pıtır", + "sabah", + "salına", + "salkım", + "sap", + "sara", + "sarı", + "serin", + "sıra", + "sırım", + "sıkı", + "sırılsıklam", + "tane", + "teker", + "tek", + "tıpış", + "top", + "tutam", + "uzun", + "üçer", + "üst", + "yalan", + "yana", + "yan", + "yarım", + "yer", + "yudum", + "yığın", + "yürekten", + "yüz", + "zaman", + "zor", +]; + +/// Turkish abbreviations that end with a period and after which lower case may continue. +const ABBREVIATIONS: &[&str] = &[ + "vb", "vs", "vd", "bkz", "bknz", "ör", "örnek", "mad", "md", "s", "sf", "sn", "dk", "sa", "yy", "m", "km", "cm", "mm", "gr", "kg", "mg", "dr", + "prof", "doç", "yrd", "av", "ist", "ank", "izm", "cad", "sok", "mah", "apt", "no", "tel", "faks", "tl", "kr", "krş", "a.ş", "ltd", "şti", "t.c", + "tbmm", "meb", "möt", "mö", "ms", +]; + +/// Conjunctions that must not be preceded by a comma. +const CONJUNCTIONS_NO_PRE_COMMA: &[&str] = &["ve", "veya", "yahut", "velev", "veyahut", "de", "da"]; + +/// Conjunctions that must not be followed by a comma. +const CONJUNCTIONS_NO_POST_COMMA: &[&str] = &["ve", "veya", "yahut", "veyahut", "hem", "ne", "ya", "gerek", "ister", "de", "da"]; + +/// Check grammar and punctuation in a Turkish text block. +pub fn check(text: &str) -> Vec { + let mut v = Vec::new(); + let ws = words(text); + + // 1. Repeated words (`kelime kelime`) + for pair in ws.windows(2) { + let (w1, w2) = match (text.get(pair[0].0..pair[0].1), text.get(pair[1].0..pair[1].1)) { + (Some(a), Some(b)) => (a, b), + _ => continue, + }; + let gap = match text.get(pair[0].1..pair[1].0) { + Some(g) => g, + None => continue, + }; + let only_spaces = gap.chars().all(|c| c == ' ' || c == '\t'); + let lower1 = w1.to_lowercase(); + let lower2 = w2.to_lowercase(); + if only_spaces && lower1 == lower2 && !ALLOWED_REPEATS.contains(&lower1.as_str()) { + v.push(Issue { + start: pair[1].0, + end: pair[1].1, + kind: IssueKind::Grammar, + message: "Yinelenen sözcük".into(), + suggestions: Vec::new(), + lang: ProofLang::Tr, + }); + } + } + + // 2. Spaces before punctuation and double spaces + let chars: Vec<(usize, char)> = text.char_indices().collect(); + for i in 0..chars.len() { + let (idx, c) = chars[i]; + if matches!(c, ',' | '.' | '?' | '!' | ';' | ':') && i > 0 && chars[i - 1].1 == ' ' { + let prev_sp = chars[i - 1].0; + // Leave emoticons and ellipses alone + let is_dot_in_dots = c == '.' && (i + 1 < chars.len() && chars[i + 1].1 == '.'); + if !is_dot_in_dots { + v.push(Issue { + start: prev_sp, + end: idx + c.len_utf8(), + kind: IssueKind::Grammar, + message: "Noktalama işaretinden önce boşluk bırakılmaz".into(), + suggestions: vec![c.to_string()], + lang: ProofLang::Tr, + }); + } + } + // Double space + if c == ' ' && i + 1 < chars.len() && chars[i + 1].1 == ' ' && (i == 0 || chars[i - 1].1 != ' ') { + let mut end = idx + 1; + while end < text.len() && text.as_bytes().get(end) == Some(&b' ') { + end += 1; + } + v.push(Issue { + start: idx, + end, + kind: IssueKind::Grammar, + message: "Fazladan boşluk".into(), + suggestions: vec![" ".into()], + lang: ProofLang::Tr, + }); + } + } + + // 3. Comma before or after conjunctions (`ali, ve veli` or `ve, veli`) + for (i, &(w_start, w_end)) in ws.iter().enumerate() { + let word = match text.get(w_start..w_end) { + Some(w) => w.to_lowercase(), + None => continue, + }; + // Check comma before conjunction + if CONJUNCTIONS_NO_PRE_COMMA.contains(&word.as_str()) + && i > 0 + && let Some(between) = text.get(ws[i - 1].1..w_start) + && let Some(comma_pos) = between.find(',') + { + let actual_pos = ws[i - 1].1 + comma_pos; + v.push(Issue { + start: actual_pos, + end: actual_pos + 1, + kind: IssueKind::Grammar, + message: format!("'{word}' bağlacından önce virgül konulmaz"), + suggestions: vec![String::new()], + lang: ProofLang::Tr, + }); + } + // Check comma after conjunction + if CONJUNCTIONS_NO_POST_COMMA.contains(&word.as_str()) + && i + 1 < ws.len() + && let Some(between) = text.get(w_end..ws[i + 1].0) + && let Some(comma_pos) = between.find(',') + { + let actual_pos = w_end + comma_pos; + v.push(Issue { + start: actual_pos, + end: actual_pos + 1, + kind: IssueKind::Grammar, + message: format!("'{word}' bağlacından sonra virgül konulmaz"), + suggestions: vec![String::new()], + lang: ProofLang::Tr, + }); + } + } + + // 4. Sentence capitalization knowing Turkish abbreviations + for (i, &(w_start, w_end)) in ws.iter().enumerate() { + if i == 0 { + // First word in paragraph + if let Some(w) = text.get(w_start..w_end) + && w.chars().next().is_some_and(char::is_lowercase) + { + let cap = turkish_capitalize(w); + v.push(Issue { + start: w_start, + end: w_end, + kind: IssueKind::Grammar, + message: "Cümle büyük harfle başlar".into(), + suggestions: vec![cap], + lang: ProofLang::Tr, + }); + } + continue; + } + + // Check if previous character sequence ended a sentence + let prev_end = ws[i - 1].1; + let prev_word = text.get(ws[i - 1].0..prev_end).unwrap_or(""); + if let Some(gap) = text.get(prev_end..w_start) { + let trimmed = gap.trim(); + if (trimmed.ends_with('.') || trimmed.ends_with('?') || trimmed.ends_with('!')) + && !trimmed.contains("...") + && !ABBREVIATIONS.contains(&prev_word.to_lowercase().as_str()) + && let Some(w) = text.get(w_start..w_end) + && w.chars().next().is_some_and(char::is_lowercase) + { + let cap = turkish_capitalize(w); + v.push(Issue { + start: w_start, + end: w_end, + kind: IssueKind::Grammar, + message: "Cümle büyük harfle başlar".into(), + suggestions: vec![cap], + lang: ProofLang::Tr, + }); + } + } + } + + v.sort_by_key(|issue| (issue.start, issue.end)); + v +} + +/// Capitalize according to Turkish rules (`i` -> `İ`, `ı` -> `I`). +pub fn turkish_capitalize(s: &str) -> String { + let mut chars = s.chars(); + let Some(first) = chars.next() else { return String::new() }; + let first_cap = match first { + 'i' => 'İ', + 'ı' => 'I', + other => other.to_uppercase().next().unwrap_or(other), + }; + let mut out = String::with_capacity(s.len() + 1); + out.push(first_cap); + out.push_str(chars.as_str()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clean_turkish_text_produces_no_issues() { + let text = "Bu bir test cümlesidir. Kitap ve defter aldım. Çocuk yavaş yavaş yürüdü."; + assert!(check(text).is_empty()); + } + + #[test] + fn repeated_words_flagged_except_allowed() { + let text = "Gitti gitti ama bulamadı."; + let issues = check(text); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].message, "Yinelenen sözcük"); + + // Allowed repeat + let text2 = "Çocuk yavaş yavaş ilerliyordu."; + assert!(check(text2).is_empty()); + } + + #[test] + fn space_before_punctuation_flagged() { + let text = "Merhaba , dünya!"; + let issues = check(text); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].message, "Noktalama işaretinden önce boşluk bırakılmaz"); + } + + #[test] + fn comma_around_conjunctions_flagged() { + let text = "Elma, ve armut."; + let issues = check(text); + assert_eq!(issues.len(), 1); + assert!(issues[0].message.contains("virgül konulmaz")); + + let text2 = "Bana kalırsa bu mesele de, haklı olan taraf bizdik."; + let issues2 = check(text2); + assert_eq!(issues2.len(), 1); + assert!(issues2[0].message.contains("'de' bağlacından sonra virgül konulmaz")); + } + + #[test] + fn sentence_capitalization_respects_abbreviations() { + let text = "Dr. ahmet geldi. Fakat geç kaldı."; + let issues = check(text); + // "Dr. ahmet" does not flag "ahmet" because "Dr." is in ABBREVIATIONS. + assert!(issues.is_empty()); + } +} diff --git a/crates/proof/src/tr/hyphen.rs b/crates/proof/src/tr/hyphen.rs new file mode 100644 index 000000000..a8a481088 --- /dev/null +++ b/crates/proof/src/tr/hyphen.rs @@ -0,0 +1,98 @@ +//! Turkish hyphenation by rule ([`ProofLang::Tr`](crate::ProofLang::Tr)). +//! +//! Turkish words are split at syllables according to Turkish Language Association (TDK) rules. +//! Because Turkish syllable boundaries are fully regular, they can be determined directly by rule +//! without needing external pattern files or word lists: +//! - Turkish vowels: `a`, `e`, `ı`, `i`, `o`, `ö`, `u`, `ü` (both lowercase and uppercase). +//! - A single consonant between two vowels joins the following syllable (`ba-lık`, `ö-ğren-ci`). +//! - Of two consonants between two vowels, the first stays with the preceding syllable and the +//! second joins the next (`alt-lık`, `sev-gi`, `tür-kçe`). +//! - Of three consonants between two vowels, the first two stay with the preceding syllable and the +//! third joins the next (`türk-çe`, `sürp-riz`). +//! - In compound words, syllable division follows pronunciation across the boundary (`ba-şöğ-ret-men`, +//! `il-ko-kul`), which matches the general vowel/consonant distribution rules. +//! +//! A single letter left at the end or start of a line is ruled out by the paragraph's +//! [`Limits`](crate::hyphen::Limits). + +/// Is `c` a Turkish vowel? +pub fn is_vowel(c: char) -> bool { + matches!(c, 'a' | 'e' | 'ı' | 'i' | 'o' | 'ö' | 'u' | 'ü' | 'A' | 'E' | 'I' | 'İ' | 'O' | 'Ö' | 'U' | 'Ü') +} + +/// Is `c` a Turkish letter? +pub(crate) fn is_letter(c: char) -> bool { + c.is_alphabetic() +} + +/// Break points of a Turkish word (char indices; a hyphen goes before that char). +/// Empty for words with fewer than two vowels or containing non-alphabetic characters. +pub fn points(word: &[char]) -> Vec { + if word.len() < 2 || !word.iter().all(|&c| is_letter(c)) { + return Vec::new(); + } + let vowels: Vec = (0..word.len()).filter(|&i| is_vowel(word[i])).collect(); + if vowels.len() < 2 { + return Vec::new(); + } + let mut out = Vec::new(); + for pair in vowels.windows(2) { + let (v1, v2) = (pair[0], pair[1]); + if v2 <= v1 { + continue; + } + let num_consonants = v2 - v1 - 1; + let pt = match num_consonants { + 0 => v1 + 1, + 1 => v2 - 1, + 2 => v1 + 2, + _ => v2 - 1, + }; + if pt > v1 && pt <= v2 { + out.push(pt); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn split(w: &str) -> String { + let chars: Vec = w.chars().collect(); + let pts = points(&chars); + let mut out = String::new(); + for (i, c) in chars.iter().enumerate() { + if pts.contains(&i) { + out.push('-'); + } + out.push(*c); + } + out + } + + #[test] + fn syllables_follow_turkish_rules() { + assert_eq!(split("balık"), "ba-lık"); + assert_eq!(split("araba"), "a-ra-ba"); + assert_eq!(split("öğrenci"), "öğ-ren-ci"); + assert_eq!(split("altlık"), "alt-lık"); + assert_eq!(split("sevgi"), "sev-gi"); + assert_eq!(split("türkçe"), "türk-çe"); + assert_eq!(split("saat"), "sa-at"); + assert_eq!(split("şiir"), "şi-ir"); + assert_eq!(split("ilkokul"), "il-ko-kul"); + assert_eq!(split("başöğretmen"), "ba-şöğ-ret-men"); + } + + #[test] + fn short_or_non_words_stay_unsplit() { + assert_eq!(split(""), ""); + assert_eq!(split("a"), "a"); + assert_eq!(split("ve"), "ve"); + assert_eq!(split("Türk"), "Türk"); + assert_eq!(split("123"), "123"); + assert_eq!(split("araba-ev"), "araba-ev"); + } +} diff --git a/crates/proof/src/tr/mod.rs b/crates/proof/src/tr/mod.rs new file mode 100644 index 000000000..e0b6fab9c --- /dev/null +++ b/crates/proof/src/tr/mod.rs @@ -0,0 +1,156 @@ +//! Turkish proofing. +//! +//! - **Spelling** against the Hunspell `tr_TR` dictionary installed on the system +//! ([`dictionary`], found by [`crate::hunspell`]: `tr_TR.aff`/`tr_TR.dic` from the OS, a +//! package manager or LibreOffice; it is not bundled). Without it Turkish text gets no spelling +//! marks. Capitalized stems are proper nouns (`Ankara`); foreign names inflected with an apostrophe +//! are accepted (`WordCraft'ta`), and the user dictionary applies as in English. +//! - **Suggestions** tuned for Turkish ([`suggest`]): ASCII-diacritic restoration (`turkce` -> `Türkçe`), +//! common confusions (`yalnış` -> `yanlış`), detached particles (`geldimi` -> `geldi mi`), and typos. +//! - **Grammar** ([`grammar`]): high-precision rules (reduplications, comma around conjunctions, +//! sentence start with abbreviations). +//! - **Hyphenation** ([`hyphen`]): syllable division by Turkish Language Association (TDK) rules. + +pub mod dict; +pub mod grammar; +pub mod hyphen; +pub mod suggest; + +use std::sync::{Arc, RwLock}; + +pub use dict::TrDict; +pub use suggest::suggest; + +/// The Turkish dictionary: `None` until first use, then the installed one (or an empty one). +static DICT: RwLock>> = RwLock::new(None); + +/// File names of a Turkish Hunspell dictionary. +const NAMES: &[&str] = &["tr_TR", "tr-TR", "tr"]; + +/// The Turkish dictionary: the installed Hunspell `tr_TR` (read on first use), one given with +/// [`install_dictionary`], or an empty one when none is available. +pub fn dictionary() -> Arc { + if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + return d.clone(); + } + let loaded = Arc::new(load_installed()); + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + slot.get_or_insert(loaded).clone() +} + +fn load_installed() -> TrDict { + let Some((aff, dic)) = crate::hunspell::find(NAMES) else { + log::info!("Turkish spelling: no Hunspell tr_TR dictionary installed"); + return TrDict::default(); + }; + let read = |p: &std::path::Path| std::fs::read_to_string(p); + match (read(&aff), read(&dic)) { + (Ok(aff_text), Ok(dic_text)) => TrDict::from_hunspell(&aff_text, &dic_text).unwrap_or_else(|e| { + log::warn!("Turkish spelling: {e}"); + TrDict::default() + }), + _ => TrDict::default(), + } +} + +/// Use this Turkish dictionary from now on. +pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> { + let d = TrDict::from_hunspell(aff, dic)?; + *DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + Ok(()) +} + +/// Reload the dictionary on next check. +pub fn reload_dictionary() { + *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; +} + +/// Is a Turkish dictionary available? +pub fn has_dictionary() -> bool { + !dictionary().is_empty() +} + +/// Is `word` spelled correctly in Turkish? +pub fn is_correct(word: &str) -> bool { + is_correct_with(&dictionary(), word) +} + +/// [`is_correct`] against a given dictionary. +pub fn is_correct_with(d: &TrDict, word: &str) -> bool { + if d.is_empty() { + return true; // No Turkish dictionary installed: no spelling marks + } + let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’'); + let w = w.trim_matches(['\'', '’']); + let n = w.chars().count(); + if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) { + return true; + } + // Check user dictionary first + if crate::in_user_dictionary(&suggest::turkish_to_lower(w)) || crate::in_user_dictionary(&w.to_lowercase()) { + return true; + } + + // Direct lookup or lowercase lookup + if d.contains(w) { + return true; + } + let lower = suggest::turkish_to_lower(w); + if d.contains(&lower) { + return true; + } + + // Handle apostrophe for proper nouns (e.g., `Ankara'da`, `WordCraft'ta`) + if let Some((stem, _suffix)) = w.split_once(['\'', '’']) + && (d.contains(stem) || d.contains(&suggest::turkish_to_lower(stem))) + { + return true; + } + + // Compound words separated by hyphens (e.g., `afyon-karahisar`) + if w.contains('-') && w.split('-').all(|part| part.is_empty() || is_correct_with(d, part)) { + return true; + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn without_dictionary_everything_is_correct() { + let empty = TrDict::default(); + assert!(is_correct_with(&empty, "herhangi")); + assert!(is_correct_with(&empty, "xyzabc")); + } + + #[test] + fn apostrophe_and_compounds_are_handled() { + let empty = TrDict::default(); + assert!(is_correct_with(&empty, "Ankara'da")); + assert!(is_correct_with(&empty, "açık-mavi")); + } + + #[test] + fn installed_dictionary_checks_words_if_present() { + // Machines without a Turkish Hunspell dictionary (CI, most desktops) have nothing to check. + if !has_dictionary() { + return; + } + assert!(is_correct("kitap")); + assert!(is_correct("kitaplar")); + assert!(is_correct("evde")); + assert!(is_correct("olacaklardan")); + assert!(is_correct("sonra")); + assert!(!is_correct("sorna")); + assert!(!is_correct("kitapppp")); + assert!(!is_correct("evdekişşş")); + + // Test user dictionary + assert!(!is_correct("benimbilmedigimkelime")); + crate::add_word("benimbilmedigimkelime"); + assert!(is_correct("benimbilmedigimkelime")); + } +} diff --git a/crates/proof/src/tr/suggest.rs b/crates/proof/src/tr/suggest.rs new file mode 100644 index 000000000..dedc1bb26 --- /dev/null +++ b/crates/proof/src/tr/suggest.rs @@ -0,0 +1,298 @@ +//! Turkish spelling suggestions, ranked by likelihood. +//! +//! Candidates are generated and ranked by cost: +//! 1. Capitalization / case adjustments (especially dotted/dotless I: `istanbul` -> `İstanbul`). +//! 2. Missing Turkish diacritics / ASCII substitutions (most frequent Turkish typing slip): +//! `c` <-> `ç`, `g` <-> `ğ`, `i` <-> `ı`, `o` <-> `ö`, `s` <-> `ş`, `u` <-> `ü` (`turkce` -> `Türkçe`). +//! 3. Common spelling mistakes and phonetic confusions (`REP` table): +//! `yalnış` -> `yanlış`, `yanlız` -> `yalnız`, `herşey` -> `her şey`, `bir kaç` -> `birkaç`. +//! 4. Missing space for detached particles and conjunctions (`bende` -> `ben de`, `geldinmi` -> `geldin mi`). +//! 5. Single typo (Damerau-Levenshtein distance <= 2). + +use std::collections::HashSet; + +use crate::tr::dict::TrDict; + +/// Common misspellings and their canonical replacements in Turkish. +const COMMON_REPLACEMENTS: &[(&str, &str)] = &[ + ("yalnış", "yanlış"), + ("yanlız", "yalnız"), + ("herşey", "her şey"), + ("herkez", "herkes"), + ("bir kaç", "birkaç"), + ("birşey", "bir şey"), + ("tabiki", "tabii ki"), + ("orjinal", "orijinal"), + ("kiprik", "kirpik"), + ("şöför", "şoför"), + ("sarmısak", "sarımsak"), + ("makina", "makine"), + ("ünvan", "unvan"), + ("rastlantı", "rastlantı"), + ("labaratuvar", "laboratuvar"), + ("laboratuar", "laboratuvar"), + ("kravat", "kravat"), + ("gardolap", "gardırop"), + ("egzoz", "egzoz"), + ("egzozt", "egzoz"), + ("egsoz", "egzoz"), + ("gelcen", "geleceksin"), + ("gelecen", "geleceksin"), + ("gitcen", "gideceksin"), + ("gidecen", "gideceksin"), + ("yapcan", "yapacaksın"), + ("yapacan", "yapacaksın"), + ("edecen", "edeceksin"), + ("deyil", "değil"), +]; + +/// Produce spelling suggestions for a misspelled Turkish word. +pub fn suggest(word: &str, max: usize) -> Vec { + let dict = super::dictionary(); + suggest_with(&dict, word, max) +} + +/// Suggest with a given dictionary. +pub fn suggest_with(d: &TrDict, word: &str, max: usize) -> Vec { + if word.is_empty() || max == 0 { + return Vec::new(); + } + + let mut suggestions = Vec::new(); + let mut seen = HashSet::new(); + + let lower = turkish_to_lower(word); + + // 1. Check direct common replacement table + for &(wrong, right) in COMMON_REPLACEMENTS { + if lower == wrong && seen.insert(right.to_string()) { + suggestions.push(right.to_string()); + if suggestions.len() >= max { + return suggestions; + } + } + } + + // 2. Diacritic substitutions (`c` -> `ç`, `g` -> `ğ`, `ı` -> `i`, `o` -> `ö`, `s` -> `ş`, `u` -> `ü`) + if !d.is_empty() { + let variants = generate_diacritic_variants(&lower); + for cand in variants { + if cand != lower && d.contains(&cand) && seen.insert(cand.clone()) { + suggestions.push(match_casing(word, &cand)); + if suggestions.len() >= max { + return suggestions; + } + } + } + } + + // 3. Detached suffix check: `-de`, `-da`, `-mi`, `-mı`, `-mu`, `-mü` + for suffix in ["de", "da", "te", "ta", "mi", "mı", "mu", "mü"] { + if lower.ends_with(suffix) && lower.len() > suffix.len() + 1 { + let stem = &lower[..lower.len() - suffix.len()]; + if d.is_empty() || d.contains(stem) { + let cand = format!("{stem} {suffix}"); + if seen.insert(cand.clone()) { + suggestions.push(match_casing(word, &cand)); + if suggestions.len() >= max { + return suggestions; + } + } + } + } + } + + // 4. Single typo (transposition, deletion, insertion, substitution) + if !d.is_empty() { + let chars: Vec = lower.chars().collect(); + let n = chars.len(); + + // 4a. Transposition (adjacent swap: `sorna` -> `sonra`) + for i in 0..n.saturating_sub(1) { + if chars.get(i) != chars.get(i + 1) { + let mut swapped = chars.clone(); + swapped.swap(i, i + 1); + let cand: String = swapped.into_iter().collect(); + if d.contains(&cand) && seen.insert(cand.clone()) { + suggestions.push(match_casing(word, &cand)); + if suggestions.len() >= max { + return suggestions; + } + } + } + } + + // Turkish alphabet for insertions and substitutions + const TR_ALPHABET: &[char] = &[ + 'a', 'b', 'c', 'ç', 'd', 'e', 'f', 'g', 'ğ', 'h', 'ı', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'ö', 'p', 'r', 's', 'ş', 't', 'u', 'ü', 'v', + 'y', 'z', + ]; + + // 4b. Deletion (one character left out: `kitapp` -> `kitap`) + for i in 0..n { + let mut deleted = chars.clone(); + deleted.remove(i); + let cand: String = deleted.into_iter().collect(); + if d.contains(&cand) && seen.insert(cand.clone()) { + suggestions.push(match_casing(word, &cand)); + if suggestions.len() >= max { + return suggestions; + } + } + } + + // 4c. Substitution (one wrong letter: `yolculuk` -> ...) + for i in 0..n { + for &c in TR_ALPHABET { + if chars.get(i) != Some(&c) { + let mut substituted = chars.clone(); + if let Some(target) = substituted.get_mut(i) { + *target = c; + } + let cand: String = substituted.into_iter().collect(); + if d.contains(&cand) && seen.insert(cand.clone()) { + suggestions.push(match_casing(word, &cand)); + if suggestions.len() >= max { + return suggestions; + } + } + } + } + } + + // 4d. Insertion (one missing letter: `kitp` -> `kitap`) + for i in 0..=n { + for &c in TR_ALPHABET { + let mut inserted = chars.clone(); + inserted.insert(i, c); + let cand: String = inserted.into_iter().collect(); + if d.contains(&cand) && seen.insert(cand.clone()) { + suggestions.push(match_casing(word, &cand)); + if suggestions.len() >= max { + return suggestions; + } + } + } + } + } + + // 5. Fallback: capitalization + let cap = super::grammar::turkish_capitalize(&lower); + if cap != word && (d.is_empty() || d.contains(&cap)) && seen.insert(cap.clone()) { + suggestions.push(cap); + } + + suggestions +} + +/// Convert ASCII letters to Turkish diacritic equivalents. +fn generate_diacritic_variants(s: &str) -> Vec { + let mut results = vec![String::new()]; + for c in s.chars() { + let (alt1, alt2) = match c { + 'c' => ('c', Some('ç')), + 'ç' => ('ç', Some('c')), + 'g' => ('g', Some('ğ')), + 'ğ' => ('ğ', Some('g')), + 'i' => ('i', Some('ı')), + 'ı' => ('ı', Some('i')), + 'o' => ('o', Some('ö')), + 'ö' => ('ö', Some('o')), + 's' => ('s', Some('ş')), + 'ş' => ('ş', Some('s')), + 'u' => ('u', Some('ü')), + 'ü' => ('ü', Some('u')), + other => (other, None), + }; + + let branch_mult = if alt2.is_some() { 2 } else { 1 }; + if results.len() * branch_mult > 1024 { + // Cap search branch explosion + for res in &mut results { + res.push(c); + } + continue; + } + + let mut next_gen = Vec::with_capacity(results.len() * branch_mult); + for existing in &results { + let mut s1 = existing.clone(); + s1.push(alt1); + next_gen.push(s1); + if let Some(c2) = alt2 { + let mut s2 = existing.clone(); + s2.push(c2); + next_gen.push(s2); + } + } + results = next_gen; + } + results +} + +/// Turkish lowercase with dotted/dotless I support. +pub fn turkish_to_lower(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + 'I' => out.push('ı'), + 'İ' => out.push('i'), + other => { + for lc in other.to_lowercase() { + out.push(lc); + } + } + } + } + out +} + +/// Match original casing on suggestion. +fn match_casing(orig: &str, cand: &str) -> String { + if orig.chars().all(char::is_uppercase) { + cand.to_uppercase() + } else if orig.chars().next().is_some_and(char::is_uppercase) { + super::grammar::turkish_capitalize(cand) + } else { + cand.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn common_replacements_are_suggested() { + let sugs = suggest("yalnış", 3); + assert!(sugs.contains(&"yanlış".to_string())); + + let sugs2 = suggest("herkez", 3); + assert!(sugs2.contains(&"herkes".to_string())); + } + + #[test] + fn detached_suffix_is_suggested() { + let sugs = suggest("geldinmi", 3); + assert!(sugs.contains(&"geldin mi".to_string())); + } + + #[test] + fn turkish_to_lower_handles_i() { + assert_eq!(turkish_to_lower("IŞIK"), "ışık"); + assert_eq!(turkish_to_lower("İSTANBUL"), "istanbul"); + } + + #[test] + fn typo_transposition_is_suggested() { + // Suggestions come from the installed Turkish dictionary; without one there are none. + if !super::super::has_dictionary() { + return; + } + let sugs = suggest("sorna", 3); + assert!(sugs.contains(&"sonra".to_string()), "sorna should suggest sonra, got {sugs:?}"); + + let sugs_gelcen = suggest("gelcen", 3); + assert!(sugs_gelcen.contains(&"geleceksin".to_string()), "gelcen should suggest geleceksin, got {sugs_gelcen:?}"); + } +} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 2a34553b1..995c61136 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -118,7 +118,14 @@ pub static LANGUAGES: [LangInfo; 20] = [ LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here. LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, - LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { + code: "tr", + name: "Türkçe", + source: include_str!("tr.tsv"), + prefer_hans: false, + editing_lang: Some("tr-TR"), + catalog: OnceLock::new(), + }, // Italian; `it-IT`, `it-CH` and `it-*` resolve here. New documents are written in // Italian: `wordcraft-proof` checks it (`ProofLang::It`). LangInfo { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 89ceb7344..e9bd531fb 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1024,7 +1024,7 @@ fn an_italian_interface_writes_new_documents_in_italian() { #[test] fn the_editing_languages_are_the_ones_wordcraft_can_proof() { let with: Vec<(&str, &str)> = Lang::all().filter_map(|l| l.editing_lang().map(|e| (l.code(), e))).collect(); - assert_eq!(with, [("pt-br", "pt-BR"), ("pl", "pl-PL"), ("it", "it-IT")]); + assert_eq!(with, [("pt-br", "pt-BR"), ("pl", "pl-PL"), ("tr", "tr-TR"), ("it", "it-IT")]); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c945f17b9..f24a4e7ad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3856,6 +3856,7 @@ mod tests { /// Types `text` after a click on `what` and checks it reached the document (#1093). fn type_after_click(h: &mut egui_kittest::Harness<'_, WordApp>, what: &str, text: &str) { + h.step(); h.step(); h.event(egui::Event::Text(text.into())); h.step(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6f180135d..68451f4c5 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -435,13 +435,14 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) } /// The proofing languages the Language menu offers: tag and English name (translated when drawn). -pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 6] = [ +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 7] = [ ("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish"), ("pt-BR", "Portuguese (Brazil)"), ("it-IT", "Italian"), ("hr-HR", "Croatian"), + ("tr-TR", "Turkish"), ]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's @@ -478,6 +479,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(), wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), wordcraft_engine::proof::ProofLang::It => "Italian".into(), + wordcraft_engine::proof::ProofLang::Tr => "Turkish".into(), wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), _ => tag.to_string(), }, @@ -585,6 +587,8 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")) || (tag == "pt-BR" && current.eq_ignore_ascii_case("pt")) || (tag == "it-IT" && current.eq_ignore_ascii_case("it")) + || (tag == "hr-HR" && current.eq_ignore_ascii_case("hr")) + || (tag == "tr-TR" && current.eq_ignore_ascii_case("tr")) || (tag == "en-US" && current.eq_ignore_ascii_case("en")) }; let checks = language_checks(¤t, listed); @@ -2656,6 +2660,14 @@ mod tests { assert_eq!(language_name(Some("PT-BR".to_string().as_str())), "Portuguese (Brazil)"); } + #[test] + fn turkish_has_a_name_in_the_language_menu() { + assert!(PROOFING_LANGUAGES.iter().any(|(t, _)| *t == "tr-TR")); + assert_eq!(language_name(Some("tr-TR")), "Turkish"); + assert_eq!(language_name(Some("tr")), "Turkish"); + assert_eq!(language_name(Some("TR-TR")), "Turkish"); + } + #[test] fn picture_format_tab_appears_when_picture_selected() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/docs/gaps.md b/docs/gaps.md index be055dfa3..ba11b7a91 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 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 | 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**: 54 modal dialogs vs Word's ~100 | 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) | -| 8 | **Proofing covers English, Polish, Portuguese and Italian only**; no dictionaries, grammar or hyphenation for the other ~46 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12) and Italian (it-IT via the installed Hunspell dictionary), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish, Portuguese and Italian | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian and Turkish only**; no dictionaries, grammar or hyphenation for the other ~44 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13) and Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish, Portuguese, Italian, Croatian and Turkish | 20–40 | [localization-parity.md](localization-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), 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 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~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) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9ac38c90e..3efd52834 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -96,6 +96,7 @@ other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). | Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, or the `WORDCRAFT_DICTIONARIES` folder; not bundled, so no Polish spelling marks without it), read at runtime by a Hunspell-style affix engine (`crates/proof/src/affix.rs`); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet | | Portuguese (`pt`, any region) | The Hunspell `pt_BR` dictionary installed on the system — VERO and its derivatives (same places as Polish; not bundled, LGPL/MPL), read at runtime by the same affix engine, which reads its `FLAG UTF-8`, 106 flags, continuation classes and `FORBIDDENWORD` spellings; names capitalized, compounds part by part (`guarda-roupa`, `ex-marido`) | missing accents first (`coraçao` → `coração`, `saude` → `saúde`), the dictionary's confusion table (`seje` → `seja`, `excessão` → `exceção`), missing space (`nãoé` → `não é`), one typo, then an accent-folding stem scan | Portuguese messages: repeated word (the emphatic doubles are allowed), space before punctuation, double space, sentence capital (knows `etc.`, `pág.`, `Dr.`, initials, ordinals, dialogue dashes), doubled punctuation; no comma rule — `que` opens a clause only sometimes | TeX Portuguese patterns (Rezende, Almeida, Araujo, Benevides; BSD 3-clause) with their 2 exception words | | Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | +| Turkish (`tr`, any region) | The Hunspell `tr_TR` dictionary installed on the system (same places as Polish; not bundled), read at runtime; proper nouns capitalized, apostrophe suffixes (`Ankara'da`, `WordCraft'ta`) and hyphenated compounds | missing diacritics / ASCII restoration first (`turkce` → `Türkçe`, `isik` → `ışık`), common confusion table (`yalnış` → `yanlış`, `herşey` → `her şey`), detached suffixes (`bende` → `ben de`, `geldinmi` → `geldi mi`), one typo | Turkish messages: repeated word (idiomatic reduplications allowed: `yavaş yavaş`), space before punctuation, double space, sentence capital (knows `vb.`, `vs.`, `prof.`, `dr.`), no comma before/after conjunctions (`ve`, `veya`, `de`), doubled punctuation | Turkish syllable rules (TDK rules); no external pattern file or word list needed | | Any other | — (no marks) | — | — | the English patterns, as before | Measured with the SJP.PL dictionary: every one of the ~4.5 million forms an independent expansion of its affix file diff --git a/docs/localization-tr.md b/docs/localization-tr.md index e8b9f4160..664b45545 100644 --- a/docs/localization-tr.md +++ b/docs/localization-tr.md @@ -54,12 +54,14 @@ and English, including dotted and dotless I and ASCII command ids. The shared ca (validation, every tab and command translated, and interface audit strings) include Turkish automatically. -Spelling, grammar and hyphenation stay English-only: this is an interface translation, not Turkish -proofing. A native-speaker skim of the catalog is welcome. +Turkish proofing (`crates/proof/src/tr`) provides spelling against installed Hunspell `tr_TR` +dictionaries, spelling suggestions tuned for Turkish diacritics and detached particles, rule-based grammar +checks (reduplications, conjunction commas, capitalization), and syllable hyphenation by TDK rules. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Turkish proofing added: spelling, suggestions, grammar rules and TDK syllable hyphenation | | 2026-10-11 | minor | Turkish catalog completed (2,325 entries, #774), covering all math structures, built-in styles, presets and preview tiles | | 2026-10-11 | minor | Turkish catalog (1,307 entries), terminology, scope and regression checks | From 3e11278a93ad922f6fd2d6b6524c88052c8b4b76 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:45:44 +0200 Subject: [PATCH 091/199] ODT and .doc import: pictures are read once, within their byte budget (#1334) (#1554) ODT: every frame inflated its picture entry again, and the byte budget was checked only after inflating, so frames pointing at an entry that didn't fit (or at any entry once the budget was used up) inflated it again each time. Each entry is now inflated at most once, and only when the size it declares fits in what is left; the bytes inflated and every later copy are charged, so an entry that inflates past its declared size uses the budget up and nothing is inflated after it. .doc: inline pictures remembered only successes, so a picture that couldn't be read or didn't fit was located, decoded and copied again for every anchor on it, and an inline shape's BLIP was decoded against the whole media cap rather than what was left. Failures are remembered too, bytes are copied and decoded only within what is left of the cap, and nothing is read once it is used up. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/docbin/src/media.rs | 51 +++++++++------ crates/docbin/src/tests.rs | 25 +++++++ crates/formats/src/odt.rs | 130 +++++++++++++++++++++++++++++++++++-- 4 files changed, 185 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1bed44d9c..64998fb9f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -243,6 +243,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources | | 2026-10-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` | | 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] | +| 2026-10-14 | ODT and `.doc` pictures stay within their byte budget (#1334): an ODT picture entry is inflated once however many frames point at it, and only when the size it declares fits in what is left (one inflating past that uses the budget up); a `.doc` inline picture that can't be read or doesn't fit is no longer decoded and copied again for every anchor on it, and is decoded only within what is left | | 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents | | 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents | | 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) | @@ -435,6 +436,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages | | 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing | | 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) | +| 2026-10-14 | trivial | Progress log: ODT and `.doc` pictures read once, within their byte budget (#1334) | | 2026-10-10 | trivial | Progress log: Draw tab ink (#307) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) | | 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) | diff --git a/crates/docbin/src/media.rs b/crates/docbin/src/media.rs index 1cb46e9e2..52e0aa910 100644 --- a/crates/docbin/src/media.rs +++ b/crates/docbin/src/media.rs @@ -19,12 +19,13 @@ const MAX_PICTURES: usize = 5_000; pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20; /// The document's pictures, keyed by their location in the Data stream: anchors that share a -/// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with -/// thousands of anchors on one huge picture costs one copy, not thousands. Pictures of the -/// drawing layer's BLIP store are shared the same way, by store index. +/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a +/// file with thousands of anchors on one huge picture costs one copy, not thousands. Pictures of +/// the drawing layer's BLIP store are shared the same way, by store index. pub(crate) struct Pictures { - /// fc_pic → the inline picture already materialised. - by_fc: HashMap, + /// fc_pic → the inline picture already materialised (`None`: unreadable or over the cap, + /// not retried). + by_fc: HashMap>, /// BLIP store index → media key (`None`: unreadable, not retried). by_bse: HashMap>, /// The media entries, merged into the document at the end. @@ -68,24 +69,34 @@ impl Pictures { /// count/byte cap is reached. pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option { if let Some(hit) = self.by_fc.get(&fc_pic) { - return Some(hit.clone()); + return hit.clone(); } if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES { return None; } - let pic = match locate(data, fc_pic)? { + let pic = self.read(data, fc_pic); + self.by_fc.insert(fc_pic, pic.clone()); + pic + } + + /// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of + /// the byte cap, and nothing at all once it is used up. + fn read(&mut self, data: &[u8], fc_pic: u32) -> Option { + let room = self.limit.saturating_sub(self.total); + if room == 0 { + return None; + } + match locate(data, fc_pic, room)? { Found::Range(pic) => { - let bytes = data.get(pic.range.clone())?.to_vec(); + let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec(); let key = self.insert(bytes, pic.ext, false)?; - Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() } + Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }) } Found::Shape { w, h, blip, props } => { let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?; - Inline { key, w, h, crop: props.crop, alt: props.alt } + Some(Inline { key, w, h, crop: props.crop, alt: props.alt }) } - }; - self.by_fc.insert(fc_pic, pic.clone()); - Some(pic) + } } /// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay` @@ -148,8 +159,9 @@ enum Found { const MM_SHAPE: u16 = 0x0064; const MM_SHAPEFILE: u16 = 0x0066; -/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData). -fn locate(data: &[u8], fc_pic: u32) -> Option { +/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData); +/// a shape's picture is decoded only up to `room` bytes. +fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option { let base = fc_pic as usize; let pic = data.get(base..)?; let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize; @@ -168,7 +180,7 @@ fn locate(data: &[u8], fc_pic: u32) -> Option { if mm == MM_SHAPEFILE { at = at.checked_add(1 + *pic.get(at)? as usize)?; } - let (blip, props) = inline_shape(pic.get(at..end)?, data)?; + let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?; // The goal size is before cropping; the crop fractions take their share off it. let [cl, ct, cr, cb] = props.crop; let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0); @@ -185,14 +197,15 @@ fn locate(data: &[u8], fc_pic: u32) -> Option { } /// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its -/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). -fn inline_shape(buf: &[u8], data: &[u8]) -> Option<(officeart::Blip, officeart::Props)> { +/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes +/// the BLIP decodes to. +fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> { let mut budget = 10_000usize; let recs = officeart::children(buf, &mut budget); let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?; let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect(); let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?; - let blip = officeart::fbse_blip(fbse, data, MAX_MEDIA_BYTES)?; + let blip = officeart::fbse_blip(fbse, data, room)?; Some((blip, sp.props)) } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 14e6f37ce..d914e5e0f 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1637,6 +1637,31 @@ fn picture_bytes_are_capped_per_document() { const { assert!(crate::media::MAX_MEDIA_BYTES <= 256 << 20) }; } +#[test] +fn rejected_inline_pictures_are_not_read_again() { + // A picture over the cap was located and copied again for every anchor on it. + let big = picf_with_png(1200); + let small = picf_with_png(300); + let data: Vec = [big.clone(), small.clone()].concat(); + let mut pics = crate::media::Pictures::default(); + pics.limit = 1000; + assert!(pics.get(&data, 0).is_none(), "over the cap"); + // The failure is remembered: that place isn't read again, whatever it holds now. + assert!(pics.get(&small, 0).is_none()); + // The rejected picture took nothing off the cap, and anchors sharing a picture share it. + let at = big.len() as u32; + let fits = pics.get(&data, at).expect("fits"); + assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key)); + assert_eq!(pics.media.len(), 1); + // Filling the cap exactly works; after that, a place never tried before isn't read. + let rest = picf_with_png(700); + let rest_at = data.len() as u32; + let tail: Vec = [data, rest.clone(), small].concat(); + assert!(pics.get(&tail, rest_at).is_some(), "700 more bytes fill the cap"); + assert!(pics.get(&tail, rest_at + rest.len() as u32).is_none(), "the cap is used up"); + assert_eq!(pics.media.len(), 2); +} + #[test] fn row_height_of_i16_min_does_not_overflow() { // sprmTDyaRowHeight = -32768: negating the i16 overflowed (a debug-build panic). diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index b13ca181a..3c6491b03 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -2051,6 +2051,51 @@ fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Option>>, + /// Bytes still allowed. + room: u64, +} + +impl Pictures { + fn new(budget: u64) -> Self { + Pictures { seen: HashMap::new(), room: budget } + } + + /// The bytes of entry `name`, or `None` when it can't be read or doesn't fit. + fn get(&mut self, z: &mut zip::ZipArchive>, name: &str) -> Option> { + if let Some(hit) = self.seen.get(name) { + let d = hit.as_ref().filter(|d| d.len() as u64 <= self.room)?; + self.room = self.room.saturating_sub(d.len() as u64); + return Some(d.clone()); + } + let d = self.inflate(z, name); + self.seen.insert(name.to_string(), d.clone()); + d + } + + /// Inflate entry `name`, charging the bytes inflated; `None` when it doesn't fit or fails. + fn inflate(&mut self, z: &mut zip::ZipArchive>, name: &str) -> Option> { + let room = self.room; + if room == 0 { + return None; + } + let f = z.by_name(name).ok()?; + if f.size() > room { + return None; + } + let mut out = Vec::new(); + let read = f.take(room.saturating_add(1)).read_to_end(&mut out); + self.room = room.saturating_sub(out.len() as u64); + (read.is_ok() && out.len() as u64 <= room).then_some(out) + } +} + fn read_meta(xml: &[u8]) -> Meta { let mut m = Meta::default(); let mut cur: Option = None; @@ -2106,11 +2151,10 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option), Str let page = pages.first.clone().unwrap_or_default(); styles.read(&content, true); let meta = read_entry(&mut z, "meta.xml").map(|m| read_meta(&m)).unwrap_or_default(); - let mut total = 0u64; + let mut pictures = Pictures::new(MAX_ENTRY); let mut fetch = |path: &str| -> Option> { - let d = read_entry(&mut z, path)?; - total += d.len() as u64; - (total <= MAX_ENTRY).then_some(d) + // A frame may point at any entry, as often as it likes: `Pictures` keeps that bounded. + pictures.get(&mut z, path) }; let mut body = Body { styles: &styles, @@ -2316,6 +2360,84 @@ mod tests { assert!(parse(b"not a zip").is_err()); } + /// A package holding `entries`, deflated. + fn zip_of(entries: &[(&str, &[u8])]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name, data) in entries { + z.start_file(*name, o).unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap().into_inner() + } + + /// `bytes` (a package) with the size its central directory declares for `name` set to `size`. + fn declare_size(mut bytes: Vec, name: &str, size: u32) -> Vec { + let at = (0..bytes.len()) + .find(|&i| bytes[i..].starts_with(b"PK\x01\x02") && bytes.get(i + 46..).is_some_and(|r| r.starts_with(name.as_bytes()))) + .unwrap(); + bytes[at + 24..at + 28].copy_from_slice(&size.to_le_bytes()); + bytes + } + + fn archive(bytes: &[u8]) -> zip::ZipArchive> { + zip::ZipArchive::new(Cursor::new(bytes)).unwrap() + } + + #[test] + fn pictures_are_inflated_once_and_every_use_is_charged() { + // Every frame on a picture inflated it again, even once the budget was used up. + let a = zip_of(&[("p.png", &[1u8; 300])]); + let b = zip_of(&[("p.png", &[2u8; 300]), ("q.png", &[3u8; 10])]); + let (mut za, mut zb) = (archive(&a), archive(&b)); + let mut pics = Pictures::new(1000); + assert_eq!(pics.get(&mut za, "p.png"), Some(vec![1u8; 300])); + // Kept from the first read: the entry isn't read again (another package's would differ). + assert_eq!(pics.get(&mut zb, "p.png"), Some(vec![1u8; 300])); + assert_eq!(pics.get(&mut zb, "p.png"), Some(vec![1u8; 300])); + assert_eq!(pics.room, 100); + // A fourth copy doesn't fit; an entry that couldn't be read isn't looked up again. + assert_eq!(pics.get(&mut za, "p.png"), None); + assert_eq!(pics.get(&mut za, "q.png"), None); + assert_eq!(pics.get(&mut zb, "q.png"), None); + assert_eq!(pics.room, 100); + } + + #[test] + fn pictures_over_the_budget_are_not_inflated() { + // An entry declaring more than is left is refused before inflating, and costs nothing. + let pkg = zip_of(&[("big.png", &[0u8; 1200]), ("ok.png", &[5u8; 300])]); + let mut z = archive(&pkg); + let mut pics = Pictures::new(1000); + assert_eq!(pics.get(&mut z, "big.png"), None); + assert_eq!(pics.room, 1000); + assert_eq!(pics.get(&mut z, "ok.png").map(|d| d.len()), Some(300)); + // One inflating to more than it declares stops a byte past the budget and uses it up: + // nothing is inflated after it. + let liar = declare_size(zip_of(&[("liar.png", &[0u8; 5000]), ("ok.png", &[5u8; 10])]), "liar.png", 10); + let mut z = archive(&liar); + let mut pics = Pictures::new(1000); + assert_eq!(pics.get(&mut z, "liar.png"), None); + assert_eq!(pics.room, 0); + assert_eq!(pics.get(&mut z, "ok.png"), None); + } + + #[test] + fn frames_sharing_a_picture_all_show_it() { + let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 300]].concat(); + let frames = r#""#.repeat(3); + let content = format!( + r#"{frames}"# + ); + let pkg = zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png)]); + let flow = parse(&pkg).unwrap(); + let imgs: Vec<&model::Img> = + paras(&flow).into_iter().flat_map(|p| &p.inlines).filter_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).collect(); + assert_eq!(imgs.len(), 3); + assert!(imgs.iter().all(|i| i.data.as_slice() == png.as_slice())); + assert_eq!(import(&pkg).unwrap().media.len(), 1); + } + const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; /// A package with `content.xml` and, when given, `styles.xml`. From 1a34583e50c5888ebfc72ab00b7190e57952b734 Mon Sep 17 00:00:00 2001 From: Kameron Kilchrist Date: Wed, 14 Oct 2026 11:45:57 -0700 Subject: [PATCH 092/199] macOS: the title bar fits around the traffic lights as macOS draws them (#1559) The traffic lights grew in macOS 26, so the zoom button overlapped AutoSave. Measure the zoom button through AppKit at start-up and size the bar and its left inset from it, keeping the old layout as the minimum and the fallback. Claude-Session: https://claude.ai/code/session_01JTqaRSzgjKv75TzSC55HEY Co-authored-by: Claude Opus 5.5 --- apps/wordcraft/Cargo.toml | 5 ++-- apps/wordcraft/src/main.rs | 7 +++++ apps/wordcraft/src/traffic_lights.rs | 26 ++++++++++++++++ crates/ui-egui/src/backstage.rs | 4 +-- crates/ui-egui/src/chrome.rs | 44 ++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 3 ++ 6 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 apps/wordcraft/src/traffic_lights.rs diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 7e79f24bc..911da17c9 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -36,8 +36,9 @@ winresource = "0.1" fmv-macos-events = "=0.1.0" # The system's Reduce motion setting (System Settings › Accessibility › Display), read through # NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard, -# at this version; this adds only the `NSAccessibility` bindings. -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] } +# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the +# traffic lights, the window, button and view ones. +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder"] } objc2 = "0.6" # Closing the window must quit through NSApplication rather than by dropping the NSWindow # inside the AppKit run loop (see `schedule_macos_terminate`). diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 62b51d4a2..91c880732 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -21,6 +21,8 @@ mod graphics; mod logging; mod print; mod region; +#[cfg(target_os = "macos")] +mod traffic_lights; use wordcraft_engine::Session; use wordcraft_ui_egui::{ @@ -330,6 +332,11 @@ fn main() -> eframe::Result { app.watch_desktop_theme(&cc.egui_ctx); app.ui.window = restored; app.integrated_titlebar = cfg!(target_os = "macos"); + #[cfg(target_os = "macos")] + if let Some(bar) = traffic_lights::title_bar() { + log::info!("title bar: {bar:?}"); + app.mac_titlebar = bar; + } let mut key_file = None; if let Some(port) = control_port && let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), wordcraft_control_key::settings_dir().as_deref()) diff --git a/apps/wordcraft/src/traffic_lights.rs b/apps/wordcraft/src/traffic_lights.rs new file mode 100644 index 000000000..124cddc9c --- /dev/null +++ b/apps/wordcraft/src/traffic_lights.rs @@ -0,0 +1,26 @@ +//! Where AppKit puts the traffic lights, so the integrated title bar fits around them (macOS). +//! +//! The lights' size and position change between macOS versions (they grew in macOS 26), so the +//! title bar measures the real buttons instead of assuming the old 28 pt layout. These calls are +//! safe in the `objc2-app-kit` bindings (our workspace forbids unsafe). + +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSWindowButton}; +use wordcraft_ui_egui::chrome::MacTitleBar; + +/// The title bar around the app window's traffic lights, or `None` before the window exists or +/// when AppKit has no zoom button to measure (the caller keeps the standard layout). +pub fn title_bar() -> Option { + let mtm = MainThreadMarker::new()?; + let window = NSApplication::sharedApplication(mtm).windows().firstObject()?; + let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?; + // In window coordinates, which count up from the bottom. + let frame = zoom.convertRect_toView(zoom.bounds(), None); + let window_height = window.frame().size.height; + let right = frame.origin.x + frame.size.width; + let centre_from_top = window_height - (frame.origin.y + frame.size.height / 2.0); + if !(right.is_finite() && centre_from_top.is_finite() && right > 0.0 && centre_from_top > 0.0) { + return None; + } + Some(MacTitleBar::around_lights(right as f32, centre_from_top as f32, frame.size.height as f32)) +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index ee3b2ac86..c13e9dd32 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -37,12 +37,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // macOS (#222): the Backstage covers the title bar, so its top band is the title // bar: the back button sits beside the traffic lights, centred with them and as far // from them as the title bar's own controls, and the rest of the band drags the window. - let (band, drag) = ui.allocate_exact_size(vec2(200.0, crate::chrome::MAC_TITLE_BAR), Sense::click_and_drag()); + let (band, drag) = ui.allocate_exact_size(vec2(200.0, app.mac_titlebar.height), Sense::click_and_drag()); if drag.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } // The chevron's stroke starts at the content edge. - let c = pos2(band.min.x + crate::chrome::MAC_CONTENT_LEFT + 3.0, band.center().y); + let c = pos2(band.min.x + app.mac_titlebar.content_left + 3.0, band.center().y); let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.id().with("backstage_back"), Sense::click()); icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE); resp diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 187fa7d54..87a73a0ac 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -197,15 +197,42 @@ const MAC_LIGHTS_LEFT: f32 = 10.0; /// from the top of the window. pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; +/// The macOS title bar as laid out around the traffic lights: its height and where content starts +/// after the lights. The app measures the real buttons (they grew in macOS 26); until then, or if it +/// can't, the standard layout above. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MacTitleBar { + pub height: f32, + pub content_left: f32, +} + +impl Default for MacTitleBar { + fn default() -> Self { + Self { height: MAC_TITLE_BAR, content_left: MAC_CONTENT_LEFT } + } +} + +impl MacTitleBar { + /// The bar for traffic lights whose right edge is `lights_right` and whose centre is + /// `lights_centre_y` below the top of the window: the lights centred in it, and content the + /// same gap after them as they keep from the top. Never smaller than the standard layout. + pub fn around_lights(lights_right: f32, lights_centre_y: f32, light: f32) -> Self { + let height = (2.0 * lights_centre_y).max(MAC_TITLE_BAR); + let gap = (height - light) / 2.0; + Self { height, content_left: (lights_right + gap).clamp(MAC_CONTENT_LEFT, f32::from(i8::MAX)) } + } +} + pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // macOS: shorter, so the system's traffic lights are centred in it; the tallest controls // shrink to keep a margin above and below them. let mac = app.integrated_titlebar; - let left = if mac { MAC_CONTENT_LEFT as i8 } else { 8 }; + let bar = app.mac_titlebar; + let left = if mac { bar.content_left as i8 } else { 8 }; let control_h = if mac { 22.0 } else { 26.0 }; egui::Panel::top("title_bar") - .exact_size(if mac { MAC_TITLE_BAR } else { 38.0 }) + .exact_size(if mac { bar.height } else { 38.0 }) .frame(egui::Frame::NONE.fill(t.title_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); @@ -596,4 +623,17 @@ mod tests { // `egui::Margin` is in `i8`. assert!(MAC_CONTENT_LEFT <= f32::from(i8::MAX)); } + + #[test] + fn mac_title_bar_follows_the_measured_lights() { + // The standard lights give the standard bar. + let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; + assert_eq!(MacTitleBar::around_lights(lights_end, MAC_TITLE_BAR / 2.0, MAC_LIGHT), MacTitleBar::default()); + // Larger, lower lights (macOS 26): a taller bar, and content clear of the green button. + let bar = MacTitleBar::around_lights(78.0, 18.0, 14.0); + assert_eq!(bar.height, 36.0); + assert_eq!(bar.content_left, 89.0); + // Never tighter than the standard layout. + assert_eq!(MacTitleBar::around_lights(40.0, 10.0, 12.0), MacTitleBar::default()); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f24a4e7ad..5c172661c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -435,6 +435,8 @@ pub struct WordApp { pub(crate) dialog_pick: Option, /// macOS: the window has no title bar; leave room for the traffic lights. pub integrated_titlebar: bool, + /// macOS: the title bar's height and where its content starts, around the traffic lights. + pub mac_titlebar: chrome::MacTitleBar, control_rx: Option>, shot_token: u64, queued_shots: Vec<(u64, f64, u32)>, @@ -563,6 +565,7 @@ impl WordApp { status_msg: None, previews: previews::Previews::default(), integrated_titlebar: false, + mac_titlebar: chrome::MacTitleBar::default(), control_rx: None, shot_token: 0, queued_shots: Vec::new(), From 6b107250a510b836710ebf9a0c2560dfaecc6197 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:03:01 -0400 Subject: [PATCH 093/199] CI: Turkish review-command test skips without a Turkish dictionary #1527's engine test needs a system Turkish Hunspell dictionary; on machines without one it failed the workspace tests. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/tests.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7ac771e4d..7c0cbb77e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4807,6 +4807,11 @@ fn polish_proofing_through_the_review_commands() { /// Review › Language and proofing commands work in Turkish end to end. #[test] fn turkish_proofing_through_the_review_commands() { + // Spelling and suggestions come from the installed Turkish Hunspell dictionary; machines + // without one (CI, most desktops) have nothing to check here. + if !crate::proof::tr::has_dictionary() { + return; + } let mut s = s(); run(&mut s, "document.setText", json!({"text": "Bana bazen gelenler oldu ise de. Sana olacaklardan sonra sorna"})); run(&mut s, "select.all", json!({})); From 1c2f0fe65e2aa0840f765a5126a78666cb91729d Mon Sep 17 00:00:00 2001 From: Nathanael Date: Thu, 15 Oct 2026 02:08:15 +0700 Subject: [PATCH 094/199] --help and -h print the usage for the app and every CLI command (#968) (#977) * --help and -h print the usage for the app and every CLI command (#968) wordcraft --help / -h printed nothing useful: the app tried to open a file named --help and started. wordcraft-cli --help failed with "unknown option --help" for every command, and -h was taken as a file name or ignored. The app now prints its options and returns before the window opens. The CLI checks for --help / -h after any known command before options are validated or files and folders opened, and prints the shared usage. Tests run both binaries for each command and flag; the app's test runs without a display, so a regression fails instead of opening a window. Co-Authored-By: Claude Opus 5.5 (1M context) * #968: test bare --help and -h on wordcraft-cli Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- apps/wordcraft-cli/src/main.rs | 32 ++++++++++++++++++++++ apps/wordcraft-cli/tests/help.rs | 47 ++++++++++++++++++++++++++++++++ apps/wordcraft/src/main.rs | 21 +++++++++++++- apps/wordcraft/tests/help.rs | 42 ++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 apps/wordcraft-cli/tests/help.rs create mode 100644 apps/wordcraft/tests/help.rs diff --git a/ROADMAP.md b/ROADMAP.md index 64998fb9f..a3849a84d 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: ODT tracked changes, gap 19; previously trivial: progress log: RTF tracked changes, gap 19; previously trivial: progress log: 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: `--help` for the app and every CLI subcommand, #968; 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. @@ -184,6 +184,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing | | 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | +| 2026-10-12 | `--help` and `-h` print the usage and exit (#968): `wordcraft --help` lists the app's options without opening a window (it used to try to open a file named `--help`), and `wordcraft-cli --help` works for every command instead of failing with `unknown option --help` | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | | 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents | @@ -364,6 +365,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329) | | 2026-10-14 | trivial | Progress log: Ink to Shape recognises single-stroke arrows | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | +| 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 6a9d44a3b..8f743c47a 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -42,6 +42,7 @@ USAGE: running): addEditCitation, addEditBibliography, addNote, refresh, removeCodes, setDocPrefs (--cmd runs first, e.g. --cmd caret.docEnd) + wordcraft-cli [] -h | --help this help wordcraft-cli --version Paper: documents created here, and HTML, Markdown and text files (which give no page size), use @@ -50,6 +51,13 @@ LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another same output on every machine. "; +/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968). +const SUBCOMMANDS: &[&str] = &["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"]; + +fn wants_help(cmd: &str, rest: &[String]) -> bool { + SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h") +} + fn open(path: &str) -> Result { // A file that gives no page size (HTML, Markdown, plain text) opens on the system region's // paper, as in the app: A4 in Italy, Letter in the US (#1381). @@ -205,6 +213,10 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io fn run(args: &[String]) -> Result<(), Failure> { let cmd = args.first().map(String::as_str).unwrap_or(""); let rest: Vec = args.iter().skip(1).cloned().collect(); + // Before options are checked or files and folders opened. + if wants_help(cmd, &rest) { + return write_stdout(|o| write!(o, "{USAGE}")); + } let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}")); match cmd { "convert" => { @@ -502,6 +514,26 @@ mod tests { assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into())); } + #[test] + fn help_or_h_after_a_subcommand_asks_for_the_usage() { + for cmd in SUBCOMMANDS { + assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}"); + assert!(wants_help(cmd, &args(&["-h"])), "{cmd}"); + assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}"); + } + assert!(!wants_help("frobnicate", &args(&["--help"]))); + } + + #[test] + fn usage_lists_exactly_the_subcommands() { + let listed: Vec<&str> = USAGE + .lines() + .filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next()) + .filter(|word| !word.starts_with(['-', '['])) + .collect(); + assert_eq!(listed, SUBCOMMANDS); + } + #[test] fn text_keeps_bullet_and_number_labels() { let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap(); diff --git a/apps/wordcraft-cli/tests/help.rs b/apps/wordcraft-cli/tests/help.rs new file mode 100644 index 000000000..dd40efa39 --- /dev/null +++ b/apps/wordcraft-cli/tests/help.rs @@ -0,0 +1,47 @@ +//! `--help` or `-h` after any subcommand prints the usage and exits 0, before options are checked +//! or files and folders opened (#968). + +use std::process::{Command, Output}; + +const SUBCOMMANDS: [&str; 10] = ["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"]; + +fn cli(args: &[&str]) -> Output { + // `output()` gives the child no stdin, so `mcp` can't wait for a client. + Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap() +} + +fn assert_prints_usage(args: &[&str]) { + let out = cli(args); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{args:?}: status {:?}, stderr: {stderr}", out.status); + assert!(String::from_utf8_lossy(&out.stdout).starts_with("wordcraft-cli — "), "{args:?}: stdout: {}", String::from_utf8_lossy(&out.stdout)); + assert!(stderr.is_empty(), "{args:?}: stderr: {stderr}"); +} + +#[test] +fn every_subcommand_prints_usage_for_help_and_h() { + assert_prints_usage(&["--help"]); + assert_prints_usage(&["-h"]); + for sub in SUBCOMMANDS { + for flag in ["--help", "-h"] { + assert_prints_usage(&[sub, flag]); + } + } +} + +#[test] +fn help_comes_before_opening_files_and_folders() { + assert_prints_usage(&["info", "missing.docx", "--help"]); + assert_prints_usage(&["convert", "missing.docx", "out.pdf", "-h"]); + assert_prints_usage(&["mcp", "--automation-read-root", "/definitely/not/a/folder", "--help"]); +} + +#[test] +fn misspelled_options_and_unknown_commands_still_fail() { + let out = cli(&["info", "--hepl"]); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("info: unknown option --hepl")); + let out = cli(&["frobnicate", "--help"]); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command `frobnicate`")); +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 91c880732..ab3d2ea2e 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -1,6 +1,6 @@ //! WordCraft desktop app. //! -//! Usage: `wordcraft [--control ] [--sample] [files…]` +//! Usage: `wordcraft [--control ] [--sample] [files…]`; `--help` prints `USAGE`. //! //! `--control ` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`. @@ -230,6 +230,20 @@ fn app_icon() -> Option { eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() } +const USAGE: &str = "\ +wordcraft — the WordCraft desktop app + +USAGE: + wordcraft [--sample] [--control PORT] [FILE…] + +OPTIONS: + --sample open the sample document + --control PORT start the localhost control server on PORT, for agents and scripts + (or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md + --version print the version + -h, --help print this help +"; + /// The commit the build came from (short), or `dev`. fn build_sha() -> &'static str { option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev") @@ -251,6 +265,11 @@ fn main() -> eframe::Result { println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha()); return Ok(()); } + // Before the window opens (#968). Unlike `print!`, a closed stdout can't panic. + "--help" | "-h" => { + let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes()); + return Ok(()); + } _ => files.push(a), } } diff --git a/apps/wordcraft/tests/help.rs b/apps/wordcraft/tests/help.rs new file mode 100644 index 000000000..67d1f078a --- /dev/null +++ b/apps/wordcraft/tests/help.rs @@ -0,0 +1,42 @@ +//! `wordcraft --help` and `-h` print the options and exit without opening a window (#968). + +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +fn app(flag: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft")) + .arg(flag) + // With no display, an app that tries to open a window fails at once instead. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WORDCRAFT_CONTROL_PORT") + .env("WORDCRAFT_NO_PREFS", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(30); + while child.try_wait().unwrap().is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + panic!("wordcraft {flag} was still running after 30 s: it started the app"); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output().unwrap() +} + +#[test] +fn help_and_h_print_the_options_and_exit() { + for flag in ["--help", "-h"] { + let out = app(flag); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success(), "{flag}: status {:?}, stderr: {stderr}", out.status); + assert!(stdout.starts_with("wordcraft — "), "{flag}: stdout: {stdout}"); + for option in ["--sample", "--control PORT", "--version", "-h, --help"] { + assert!(stdout.contains(option), "{flag}: {option} missing from: {stdout}"); + } + assert!(stderr.is_empty(), "{flag}: stderr: {stderr}"); + } +} From 5311069ae14bd980bcc3f174cbfb5a747fcf7739 Mon Sep 17 00:00:00 2001 From: Navid Zaman Khan Date: Thu, 15 Oct 2026 01:08:27 +0600 Subject: [PATCH 095/199] fix(cli): reject invalid page and scale arguments (#982) --- apps/wordcraft-cli/src/main.rs | 231 ++++++++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 4 deletions(-) diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 8f743c47a..e9b86da24 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -96,6 +96,58 @@ fn arg_value(args: &[String], name: &str) -> Option { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned() } +enum OptionArg<'a> { + Absent, + Duplicate, + MissingValue, + Value(&'a str), +} + +fn find_option_arg<'a>(args: &'a [String], name: &str) -> OptionArg<'a> { + let mut matches = args.iter().enumerate().filter(|(_, a)| *a == name); + let Some((i, _)) = matches.next() else { + return OptionArg::Absent; + }; + if matches.next().is_some() { + return OptionArg::Duplicate; + } + match args.get(i + 1) { + None => OptionArg::MissingValue, + Some(val) if val.starts_with("--") => OptionArg::MissingValue, + Some(val) => OptionArg::Value(val.as_str()), + } +} + +fn parse_page_arg(command: &str, args: &[String]) -> Result, String> { + match find_option_arg(args, "--page") { + OptionArg::Absent => Ok(None), + OptionArg::Duplicate => Err(format!("{command}: duplicate --page option")), + OptionArg::MissingValue => Err(format!("{command}: --page requires a value")), + OptionArg::Value(val) => { + let n = val.parse::().map_err(|_| format!("{command}: invalid --page `{val}`: not a valid integer"))?; + if n == 0 { + return Err(format!("{command}: invalid --page `0`: page numbers start at 1")); + } + Ok(Some(n)) + } + } +} + +fn parse_scale_arg(command: &str, args: &[String]) -> Result, String> { + match find_option_arg(args, "--scale") { + OptionArg::Absent => Ok(None), + OptionArg::Duplicate => Err(format!("{command}: duplicate --scale option")), + OptionArg::MissingValue => Err(format!("{command}: --scale requires a value")), + OptionArg::Value(val) => { + let scale = val.parse::().map_err(|_| format!("{command}: invalid --scale `{val}`: not a number"))?; + if !scale.is_finite() || scale <= 0.0 { + return Err(format!("{command}: invalid --scale `{val}`: scale must be a positive number")); + } + Ok(Some(scale)) + } + } +} + #[derive(Clone, Copy)] struct OptionSpec { name: &'static str, @@ -267,14 +319,16 @@ fn run(args: &[String]) -> Result<(), Failure> { "render" => { validate_options("render", &rest, RENDER_OPTIONS)?; let (i, o) = (pos(0)?, pos(1)?); + let page = parse_page_arg("render", &rest)?.unwrap_or(1); + let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0); let mut s = open(&i)?; - let page = arg_value(&rest, "--page").and_then(|p| p.parse::().ok()).unwrap_or(1); - let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::().ok()).unwrap_or(2.0); let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?; write_stdout(|o| writeln!(o, "{r}")) } "run" => { validate_options("run", &rest, RUN_OPTIONS)?; + let page = parse_page_arg("run", &rest)?; + let scale = parse_scale_arg("run", &rest)?; let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) { (Some(f), _) => open(&f)?, (None, Some(t)) => { @@ -307,10 +361,10 @@ fn run(args: &[String]) -> Result<(), Failure> { if let Some(out) = arg_value(&rest, "--save") { let r = if out.to_ascii_lowercase().ends_with(".png") { let mut v = json!({"path": out}); - if let Some(n) = arg_value(&rest, "--page").and_then(|p| p.parse::().ok()) { + if let Some(n) = page { v["page"] = json!(n); } - if let Some(k) = arg_value(&rest, "--scale").and_then(|p| p.parse::().ok()) { + if let Some(k) = scale { v["scale"] = json!(k); } s.run("file.exportPng", &v) @@ -606,4 +660,173 @@ mod tests { let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string(); assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}"); } + + #[test] + fn render_rejects_missing_or_invalid_page_and_scale() { + // Missing operands + assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into())); + assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into())); + assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into())); + assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into())); + + // Non-numeric operands + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])), + Err("render: invalid --page `banana`: not a valid integer".into()) + ); + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])), + Err("render: invalid --scale `banana`: not a number".into()) + ); + + // Out-of-range values + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--page", "0"])), + Err("render: invalid --page `0`: page numbers start at 1".into()) + ); + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])), + Err("render: invalid --page `-1`: not a valid integer".into()) + ); + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])), + Err("render: invalid --scale `0`: scale must be a positive number".into()) + ); + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])), + Err("render: invalid --scale `-2`: scale must be a positive number".into()) + ); + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])), + Err("render: invalid --scale `NaN`: scale must be a positive number".into()) + ); + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])), + Err("render: invalid --scale `inf`: scale must be a positive number".into()) + ); + + // Duplicate options + assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into())); + assert_eq!( + run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])), + Err("render: duplicate --scale option".into()) + ); + } + + #[test] + fn run_rejects_missing_or_invalid_page_and_scale() { + // Missing operands + assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into())); + assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into())); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])), + Err("run: --page requires a value".into()) + ); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])), + Err("run: --scale requires a value".into()) + ); + + // Non-numeric operands + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])), + Err("run: invalid --page `banana`: not a valid integer".into()) + ); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])), + Err("run: invalid --scale `banana`: not a number".into()) + ); + + // Out-of-range values + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])), + Err("run: invalid --page `0`: page numbers start at 1".into()) + ); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])), + Err("run: invalid --page `-1`: not a valid integer".into()) + ); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])), + Err("run: invalid --scale `0`: scale must be a positive number".into()) + ); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])), + Err("run: invalid --scale `-1`: scale must be a positive number".into()) + ); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])), + Err("run: invalid --scale `NaN`: scale must be a positive number".into()) + ); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])), + Err("run: invalid --scale `inf`: scale must be a positive number".into()) + ); + + // Duplicate options + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])), + Err("run: duplicate --page option".into()) + ); + assert_eq!( + run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])), + Err("run: duplicate --scale option".into()) + ); + } + + #[test] + fn render_and_run_valid_options_and_defaults_produce_files() { + let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let docx_path = dir.join("sample.docx"); + // Create sample docx via `run --save sample.docx` + assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok()); + assert!(docx_path.exists()); + + let docx_str = docx_path.to_string_lossy().into_owned(); + + // 1. render with defaults + let out_default = dir.join("out_default.png"); + assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok()); + assert!(out_default.exists()); + + // 2. render with valid --page and --scale + let out_valid = dir.join("out_valid.png"); + assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok()); + assert!(out_valid.exists()); + + // 3. render with different flag order + let out_reordered = dir.join("out_reordered.png"); + assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok()); + assert!(out_reordered.exists()); + + // 4. render failure leaves no output file + let out_invalid = dir.join("out_invalid.png"); + assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err()); + assert!(!out_invalid.exists()); + + // 5. run --save out.png with defaults + let run_default = dir.join("run_default.png"); + assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok()); + assert!(run_default.exists()); + + // 6. run --save out.png with --page and --scale + let run_valid = dir.join("run_valid.png"); + assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok()); + assert!(run_valid.exists()); + + // 7. run --save out.png with different flag order + let run_reordered = dir.join("run_reordered.png"); + assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok()); + assert!(run_reordered.exists()); + + // 8. run --save out.png failure leaves no output file + let run_invalid = dir.join("run_invalid.png"); + assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err()); + assert!(!run_invalid.exists()); + + let _ = std::fs::remove_dir_all(dir); + } } From 639a4043b885f9f24fbc07d825eb6d1522a89271 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 21:08:38 +0200 Subject: [PATCH 096/199] Native print test: fixed PDF creation date, no flaky byte comparison (#987) (#990) Without a creation date in the document, each PDF is stamped with the current time, so the expected PDF and the submitted one differed whenever a clock second ticked over between them (seen on the macOS runner). The test document now has a fixed creation date, and the test checks that both PDFs carry it. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/tests/native_print.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/native_print.rs index 30a3220ef..7ea822e46 100644 --- a/crates/ui-egui/tests/native_print.rs +++ b/crates/ui-egui/tests/native_print.rs @@ -170,7 +170,11 @@ fn repeated_fast_jobs_each_surface_an_identical_receipt() { #[test] fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { - let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + // A fixed creation date: without one each PDF is stamped with the current time, and the two + // compared below differ when a clock second ticks over between them (#987). + let mut doc = wordcraft_doc::Document::new(); + doc.core.created = "2026-01-01T00:00:00Z".into(); + let mut app = WordApp::new(Session::new(doc), Services::default()); for page in 1..=3 { app.run("text.insert", json!({"text":format!("Distinct page {page}")})).unwrap(); if page < 3 { @@ -197,6 +201,8 @@ fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { assert_eq!(plan.pages, vec![0, 2]); app.run("ui.printSubmit", params).unwrap(); let jobs = jobs.borrow(); + let stamped = |pdf: &[u8]| pdf.windows(16).any(|w| w == b"D:20260101000000"); + assert!(stamped(&expected) && stamped(&jobs[0].0), "both PDFs carry the document's own creation date"); assert_eq!(jobs[0].0, expected, "the PDF contains only the two selected pages, once each"); assert_eq!(jobs[0].1.copies, 3, "copies go to the spooler, not the PDF"); assert!(!jobs[0].2, "uncollated copies reach the spooler"); From 987c41364d0fe672625a9e509384ad9d960887a1 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:08:57 +0200 Subject: [PATCH 097/199] Tables: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end (#1004) * Tables: AutoFit in a nested table fits within its cell (#971) A double-click on a nested table's column border let the table widen up to the page's right margin, far past the cell around it. It now widens only up to the right edge of that cell's text (a right-to-left table's start margin on the right); the columns right of the border still keep their widths. Co-Authored-By: Claude Opus 5.5 * Tables: a selection through a table shades its rows' whole cells (#971) A selection from text into or across a table takes whole rows (#722), but only the letters in the cells were highlighted. Every row it takes from start to end now shades all its cells (an empty one too), as selected cells do, and the letters in them aren't shaded twice (`table_mouse::selection_rects`, used by the canvas). A selection that starts and ends in one table keeps its text highlight, so selecting all the text of a cell doesn't look like selecting the cell; a nested table in a shaded row shades with it. Co-Authored-By: Claude Opus 5.5 * Typing: Shift+Right at a cell's end selects the cell first (#971) Shift+Right out of the cell the selection is in selected that cell and the next one at once. As in Word, the first step out of the cell now takes the cell alone (its end-of-cell mark) and the next press goes on into the next cell; in an empty cell the first press selects it. Shift+Left in right-to-left text, the forward step there, does the same. Pinned in `tests_typing.rs` and `docs/typing-parity.md`; progress docs for all three table fixes of #971. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/column.rs | 7 +- crates/engine/src/cmd/table.rs | 33 ++++- crates/engine/src/cmd/table_mouse.rs | 172 ++++++++++++++++++++++++++- crates/engine/src/tests_typing.rs | 23 ++++ crates/ui-egui/src/canvas.rs | 15 ++- docs/typing-parity.md | 4 +- docs/ui-parity.md | 5 +- 8 files changed, 248 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a3849a84d..0f9166099 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: `--help` for the app and every CLI subcommand, #968; 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end, #971; 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. @@ -188,6 +188,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | | 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents | +| 2026-10-12 | Table fixes (#971): a double-click on a nested table's column border fits the column within the cell around the table (it could run out to the page's right margin); a selection from text into or across a table shades the whole cells of the rows it takes, not just their letters; Shift+Right at the end of a cell's text selects that cell first, as in Word, and the next press takes the next cell too | | 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged | | 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon | | 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` | @@ -370,6 +371,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | | 2026-10-11 | trivial | Progress log: the table context menu | +| 2026-10-12 | trivial | Progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end (#971) | | 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells | | 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 | | 2026-10-12 | trivial | Progress log: Undo history list (#701) | diff --git a/crates/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs index faff98ec7..2fc559502 100644 --- a/crates/engine/src/cmd/column.rs +++ b/crates/engine/src/cmd/column.rs @@ -145,13 +145,18 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se // runs, then takes whole cells or rows the way Word does around tables. if id.starts_with("caret.") && p::bool(v, "extend") == Some(true) { let shown = s.sel.clone(); + let cells = s.cell_range().cloned(); s.sel = s.sel_ends().clone(); + let ends = s.sel.clone(); if let Err(e) = run(s, v) { s.sel = shown; return Err(e); } let to = s.sel.focus.clone(); - super::table::extend_selection(s, to); + // Shift+Right out of a cell takes the cell alone first. + if !(matches!(id, "caret.right" | "caret.left") && super::table::select_cell_first(s, &ends, &to, cells.as_ref())) { + super::table::extend_selection(s, to); + } return super::sel_result(s); } let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) }; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 071dc6f3a..8218eee64 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -970,6 +970,37 @@ pub(crate) fn extend_selection(s: &mut Session, to: Pos) { s.goal_x = goal_x; } +/// Shift+Right (Shift+Left in right-to-left text) out of the cell the selection is in takes that +/// cell whole first, as Word does (its end-of-cell mark); the next press goes on into the next +/// cell. `ends` is where the selection ran before the step, `to` where the step went, `cells` the +/// whole cells selected before it. Whether it took the cell. +pub(crate) fn select_cell_first(s: &mut Session, ends: &Selection, to: &Pos, cells: Option<&crate::CellRange>) -> bool { + let (a, f) = (&ends.anchor, &ends.focus); + if a.story != f.story || to.story != f.story || to <= f { + return false; + } + // The innermost cell both ends are in. + let mut cell = None; + let mut i = 0; + while let (Some(x), Some(y)) = (cell_at(&a.path.0, i), cell_at(&f.path.0, i)) { + if x != y { + break; + } + cell = Some(x); + i += 3; + } + let Some((tp, r, c)) = cell else { return false }; + // Still in the cell, or that cell alone is already selected: nothing to take first. + if f.path.0.get(..tp.0.len() + 2).is_some_and(|prefix| to.path.0.starts_with(prefix)) { + return false; + } + let Some(cols) = s.doc.table(f.story, &tp).map(|t| grid_cols(t, r, c)) else { return false }; + if cells.is_some_and(|cr| cr.story == f.story && cr.table == tp && cr.rows == (r, r) && cr.cols == cols) { + return false; + } + select_range(s, f.story, &tp, (r, r), cols).is_ok() +} + /// Widen the selection between `ends` (already `s.sel`) by Word's table rule (see /// `extend_selection`). fn widen(s: &mut Session, ends: &Selection) { @@ -1027,7 +1058,7 @@ fn cell_at(p: &[u32], i: usize) -> Option<(Path, usize, usize)> { } /// Where row `r` of table `tp` starts and ends: its first cell's start and its last cell's end. -fn row_ends(doc: &Document, story: StoryRef, tp: &Path, r: usize) -> Option<(Pos, Pos)> { +pub(crate) fn row_ends(doc: &Document, story: StoryRef, tp: &Path, r: usize) -> Option<(Pos, Pos)> { let mut row = tp.0.clone(); row.push(u32::try_from(r).ok()?); let mut paths = doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&row)); diff --git a/crates/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index 0bb1eb50b..abc600b77 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -7,11 +7,13 @@ //! column left of it to its contents. The app sends each click or finished drag here in page //! points, so each is one command (one undo step when it changes the document). +use std::collections::BTreeMap; + use serde_json::Value; use wordcraft_doc::para::Anchor; use wordcraft_doc::props::TableFloat; use wordcraft_doc::{Document, Path, Pos, StoryRef, Table}; -use wordcraft_geom::Rect; +use wordcraft_geom::{Point, Rect}; use wordcraft_layout::{DocLayout, Placed}; use super::table_resize::{self, CellBox, Edge, REACH, ResizeTarget}; @@ -251,6 +253,80 @@ pub fn cell_rects(l: &DocLayout, doc: &Document, range: &CellRange) -> Vec<(usiz out } +/// What a stream selection from `a` to `b` shades, as in Word: its text, except that each row of +/// a table it takes from start to end (running into or across the table, `table::extend_selection` +/// takes whole rows) shades all its cells instead (an empty one too), on every page. A table +/// both ends are in keeps its text (whole cells there are a `CellRange`). +pub fn selection_rects(l: &DocLayout, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> { + let text = l.selection_rects(doc, a, b, page_hint); + let (a, b) = if a <= b { (a, b) } else { (b, a) }; + if a.story != b.story { + return text; + } + let mut seen = BTreeMap::new(); + let mut cells = Vec::new(); + for (pi, pg) in l.pages.iter().enumerate() { + for it in &pg.items { + // A vertically merged cell's box spans its rows (#640), as in `cell_rects`. + if let Placed::Cell { rect, table, row, story, covered: false, .. } = it + && *story == a.story + && row_taken(doc, a, b, table, *row, &mut seen) + && !outer_row_taken(doc, a, b, table, &mut seen) + { + cells.push((pi, *rect)); + } + } + } + if cells.is_empty() { + return text; + } + let mut out: Vec<(usize, Rect)> = text + .into_iter() + .filter(|(pi, r)| { + let mid = Point::new(r.x + r.w / 2.0, r.y + r.h / 2.0); + !cells.iter().any(|(cp, c)| cp == pi && c.contains(mid)) + }) + .collect(); + out.extend(cells); + out +} + +/// Whether the selection `a`..`b` (in order, one story) takes row `r` of table `tp` whole: it +/// runs from the row's start (or before it) to its end (or past it) and doesn't both start and +/// end in the table. Remembered in `seen`. +fn row_taken(doc: &Document, a: &Pos, b: &Pos, tp: &Path, r: usize, seen: &mut BTreeMap<(Path, usize), bool>) -> bool { + if let Some(&taken) = seen.get(&(tp.clone(), r)) { + return taken; + } + let inside = |p: &Pos| p.path.0.len() > tp.0.len() && p.path.0.starts_with(&tp.0); + let taken = if inside(a) && inside(b) { + false + } else { + let mut row = tp.0.clone(); + row.push(u32::try_from(r).unwrap_or(u32::MAX)); + let (a_in, b_in) = (a.path.0.starts_with(&row), b.path.0.starts_with(&row)); + // Where the row starts and ends matters only for an end in it. + let ends = if a_in || b_in { table::row_ends(doc, a.story, tp, r) } else { None }; + let from = if a_in { ends.as_ref().is_some_and(|(start, _)| start == a) } else { a.path.0 < row }; + let to = if b_in { ends.as_ref().is_some_and(|(_, end)| end == b) } else { b.path.0 > row }; + from && to + }; + seen.insert((tp.clone(), r), taken); + taken +} + +/// Whether a row of a table that table `tp` is nested in is taken whole (it shades instead). +fn outer_row_taken(doc: &Document, a: &Pos, b: &Pos, tp: &Path, seen: &mut BTreeMap<(Path, usize), bool>) -> bool { + let mut p = tp.clone(); + while let Some((outer, r, _)) = p.cell() { + if row_taken(doc, a, b, &outer, r, seen) { + return true; + } + p = outer; + } + false +} + /// The click: `page`, `x`, `y` and the resize `reach`. fn point(v: &Value) -> Result<(usize, f32, f32, f32), CmdError> { let page = @@ -418,8 +494,9 @@ fn fit_column(s: &mut Session, v: &Value) -> CmdResult { wordcraft_layout::measure_table_columns(&s.doc, t).get(g).copied().ok_or_else(|| CmdError::Failed("there's no such column".into()))?; let current = target.widths().get(g).copied().unwrap_or(0.0); // As in Word, the columns right of it keep their widths and move with the border, so the - // table narrows or widens: up to the right margin, never below the column's narrowest content. - let room = current + l.pages.get(page).map_or(0.0, |pg| (pg.body.right() - target.right()).max(0.0)); + // table narrows or widens: up to the right margin (a nested table's cell's), never below the + // column's narrowest content. + let room = current + (room_right(&l, &s.doc, page, &target) - target.right()).max(0.0); let want = widest.min(room.max(narrowest)); if let Some(w) = target.plan(want - current, 0.0, true).widths { let t = s.doc.table_mut(target.story, &target.table)?; @@ -429,6 +506,22 @@ fn fit_column(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// Where the room right of table `target` ends on page `page` (page x): the right margin, or +/// for a table nested in a cell the right edge of that cell's text. +fn room_right(l: &DocLayout, doc: &Document, page: usize, target: &ResizeTarget) -> f32 { + let margin = l.pages.get(page).map_or(target.right(), |pg| pg.body.right()); + let Some((outer, row, cell)) = target.table.cell() else { return margin }; + let Some(t) = doc.table(target.story, &outer) else { return margin }; + let boxes = table_resize::cell_boxes(l, page); + let Some(b) = boxes.iter().find(|b| b.story == target.story && b.table == outer && b.row == row && b.cell == cell) else { + return margin; + }; + // A right-to-left table has its cells' start (left) margin on their right. + let m = wordcraft_layout::cell_margins(doc, t, row, cell); + let inset = if t.props.bidi_visual { m[1] } else { m[3] }; + (b.rect.right() - inset).min(margin) +} + #[cfg(test)] mod tests { use super::*; @@ -736,6 +829,44 @@ mod tests { assert!((c2.w - fit[2].1).abs() < 0.5 && c2.w < c.w, "{c:?} → {c2:?}"); } + /// A nested table's column fits within the cell around the table: the table widens only to + /// the right edge of that cell's text, not to the page's right margin, and the table around + /// it keeps its columns. + #[test] + fn a_nested_tables_column_fits_within_its_cell() { + let mut s = table_session(1, 2); + s.sel = Selection::caret(cell_pos(&s, 0, 0)); + s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap(); + let (inner, _, _) = s.sel.focus.path.cell().unwrap(); + let rect = |s: &mut Session, table: &Path, cell: usize| { + s.layout().pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Cell { rect, table: t, row: 0, cell: c, .. } if t == table && *c == cell => Some(*rect), + _ => None, + }) + .unwrap() + }; + // Its empty columns narrow first, leaving room right of it in its cell. + for col in [1, 0] { + let r = rect(&mut s, &inner, col); + s.run("table.fitColumn", &json!({"page": 0, "x": r.right(), "y": mid_y(r)})).unwrap(); + } + s.run("text.insert", &json!({"text": "word ".repeat(40)})).unwrap(); + let outer = table_path(&s); + let host = rect(&mut s, &outer, 0); + let (a, b) = (rect(&mut s, &inner, 0), rect(&mut s, &inner, 1)); + assert!(b.right() < host.right() - 40.0, "{b:?} in {host:?}"); + s.run("table.fitColumn", &json!({"page": 0, "x": a.right(), "y": mid_y(a)})).unwrap(); + let (a1, b1) = (rect(&mut s, &inner, 0), rect(&mut s, &inner, 1)); + let text_right = host.right() - wordcraft_layout::cell_margins(&s.doc, &first_table(&s), 0, 0)[3]; + assert!(a1.w > a.w + 40.0 && (b1.w - b.w).abs() < 0.5, "{a:?} {b:?} → {a1:?} {b1:?}"); + assert!((b1.right() - text_right).abs() < 0.5, "the table fills its cell's text: {b1:?}, {text_right}"); + let host1 = rect(&mut s, &outer, 0); + assert_eq!((host1.x, host1.w), (host.x, host.w), "the table around it keeps its columns"); + } + /// Pasting formatting onto a selected column leaves its text (it used to clear it). #[test] fn the_format_painter_keeps_a_selected_columns_text() { @@ -835,6 +966,41 @@ mod tests { assert_eq!(s.sel, Selection { anchor: Pos::body(0, 2), focus: Pos { off: 4, ..cell_pos(&s, 1, 2) } }); } + /// A drag from the text before a table into it takes whole rows, and they shade every cell + /// of them (an empty one too), not just their letters; the rows it doesn't take and a table + /// both ends are in shade their text. + #[test] + fn a_selection_into_a_table_shades_its_rows_whole_cells() { + let mut s = Session::new(Document::new()); + s.run("text.insert", &json!({"text": "Intro"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("insert.table", &json!({"rows": 3, "cols": 2})).unwrap(); + fill(&mut s); + let emptied = cell_pos(&s, 0, 1); + s.sel = Selection { anchor: emptied.clone(), focus: Pos { off: 4, ..emptied } }; + s.run("text.delete", &json!({})).unwrap(); + assert!(para(&s, 0, 1).is_empty()); + s.sel = Selection::caret(Pos::body(0, 2)); + let into = cell_pos(&s, 1, 0); + drag_to(&mut s, into); + let (a, b) = s.sel.ordered(); + let l = s.layout(); + let shaded = selection_rects(&l, &s.doc, &a, &b, 0); + for r in 0..3 { + for c in 0..2 { + let cell = cell_rect(&mut s, r, c); + assert_eq!(shaded.iter().filter(|(_, x)| *x == cell).count(), usize::from(r < 2), "cell {r},{c}"); + } + } + let first = cell_rect(&mut s, 0, 0); + let on = |x: &Rect| first.contains(Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0)); + assert_eq!(shaded.iter().filter(|(_, x)| on(x)).count(), 1, "the cell, its letters not again"); + assert!(shaded.iter().any(|(_, x)| x.bottom() <= first.y), "and the text before the table"); + // Both ends in the table (here a whole row of it): its text. + let (start, end) = table::row_ends(&s.doc, StoryRef::Body, &table_path(&s), 2).unwrap(); + assert_eq!(selection_rects(&l, &s.doc, &start, &end, 0), l.selection_rects(&s.doc, &start, &end, 0)); + } + /// Clicks away from the areas, and hostile numbers: an error and no change, never a panic. #[test] fn misses_and_hostile_params_change_nothing() { diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index f7a262824..1fb043840 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -853,6 +853,29 @@ fn shift_f5_goes_back_through_the_last_edits() { assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet"); } +#[test] +fn shift_right_at_the_end_of_a_cell_selects_the_cell_first() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let (table, _, _) = s.sel.focus.path.cell().unwrap(); + typ(&mut s, "ab"); + run(&mut s, "caret.left", json!({})); + let shift_right = |s: &mut Session| { + run(s, "caret.right", json!({"extend": true})); + s.cell_range().map(|r| (r.rows, r.cols)) + }; + assert_eq!(shift_right(&mut s), None, "the letter first"); + assert_eq!((s.sel.anchor.off, s.sel.focus.off), (1, 2)); + assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 0))), "then the cell alone"); + assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 1))), "then the next cell with it"); + // In an empty cell the first press takes it. + let mut empty = table.0.clone(); + empty.extend([1, 0, 0]); + let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path(empty), off: 0 }; + run(&mut s, "caret.set", json!({"pos": pos})); + assert_eq!(shift_right(&mut s), Some(((1, 1), (0, 0)))); +} + #[test] fn two_initial_capitals_are_corrected() { let s = typed("the WOrd and THe end, not IDs or CDs. TV is fine "); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 691f48337..354a06a5b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1080,17 +1080,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } // Selection (a selected object shows its frame instead): whole table cells shade the - // cells (an empty one too), a column selection each row's piece. + // cells (an empty one too), a column selection each row's piece, text running into or + // across a table the whole cells of the rows it takes. let selected: Vec<_> = if crate::objects::selected(app).is_some() { Vec::new() } else if let Some(range) = app.session.cell_range() { wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range) } else if !app.session.sel.is_collapsed() { - let pieces: Vec<(Pos, Pos)> = match app.session.column_segments() { - Some(segs) => segs.to_vec(), - None => vec![app.session.sel.ordered()], - }; - pieces.iter().flat_map(|(a, b)| layout.selection_rects(&app.session.doc, a, b, app.session.page_hint)).collect() + match app.session.column_segments() { + Some(segs) => segs.iter().flat_map(|(a, b)| layout.selection_rects(&app.session.doc, a, b, app.session.page_hint)).collect(), + None => { + let (a, b) = app.session.sel.ordered(); + wordcraft_engine::cmd::table_mouse::selection_rects(&layout, &app.session.doc, &a, &b, app.session.page_hint) + } + } } else { Vec::new() }; diff --git a/docs/typing-parity.md b/docs/typing-parity.md index f1406719f..89c58fdff 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-14 · **Change:** trivial (Alt+numpad character codes, #961; previously trivial: Track Changes: table deletes, Change Case and Unlink Fields keep Reject All working and respect locked content controls (#1329); 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-14 · **Change:** trivial (Alt+numpad character codes, #961; previously trivial: Shift+Right at the end of a cell's text selects the cell first, #971; 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 @@ -75,6 +75,7 @@ Windows/Linux keys are given first, macOS second. | Shift+F5 | Go Back: the caret cycles through the last four places edited, most recent first | `shift_f5_goes_back_through_the_last_edits` | | Alt+X (no macOS default) | the hex code before the caret (up to six digits, `U+` optional) or the selected code becomes its character; otherwise the character before the caret becomes its code (`é` ↔ `00E9`); one undo step | `alt_x_toggles_between_a_character_and_its_code` | | Alt+numpad code (Windows and Linux; not macOS) | hold Alt, type a decimal code, release Alt: the character appears at the caret as typed text (one undo step). With a leading 0 the code is the ANSI code page — Windows-1252 by default (`0233` → é, `0128` → €); without one, 1–255 is the OEM code page — 437 by default and in US English (`233` → Θ, `1` → ☺, `7` → •), 850 for Western European locales and English outside the US (`155` → ø; `233` is Ú there, per the CP850 mapping file — é is `130` in both). On Windows the system locale picks both pages (`text.typeCode` `ansi`/`oem`): Central European 1250/852 (Polish `0185` and `165` → ą), Cyrillic 1251/866, Greek 1253/737, Turkish 1254/857, and for codes up to 255 the character Windows composes itself replaces ours (it knows every code page, double-byte ones too); Linux keeps 1252 and 437/850. Above 255, with or without the 0, the Unicode character with that decimal value (`8364` → €). Control characters, 0, unassigned bytes and values past Unicode type nothing; auto-repeat of a held digit adds nothing, and switching windows drops a half-typed code. Word counts only keypad digits; egui reports keypad and top-row digits alike, so top-row digits with Alt alone compose codes too. An Alt code never opens KeyTips | `alt_numpad_codes_map_like_word_on_windows`, `alt_numpad_code_types_one_character`, ui-egui `alt_numpad_code_types_its_character_without_keytips` | +| Shift+Right (⇧→) at the end of a cell's text | the whole cell is selected first (its end-of-cell mark); the next Shift+Right selects the next cell with it. In an empty cell the first press selects the cell | `shift_right_at_the_end_of_a_cell_selects_the_cell_first` | ## AutoCorrect and AutoFormat as you type @@ -199,6 +200,7 @@ Word. | 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) | | 2026-10-12 | 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) | | 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) | +| 2026-10-12 | trivial | Editing keys: Shift+Right at the end of a cell's text selects the cell first, then the next cell with it (#971) | | 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) | | 2026-10-12 | trivial | AutoCorrect: untagged text follows the editing language; English decided as proofing does (#835 review) | | 2026-10-11 | trivial | Grammar proofing (not AutoCorrect) no longer suggests capitalising a sentence after `...` or `…` (`proof::tests::ellipses_do_not_start_sentences`) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5fe578d77..2e8fb94ee 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: Ink to Shape recognises arrows; 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-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: AutoFit in a nested table, whole cells shaded for a selection through a table, #971; 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 @@ -24,8 +24,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 85% | 1–2 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — | -| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it; selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows. Missing: a single selected cell's border | ✅ | 95% | 1 | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Crop handles missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 50% | 2–3 | +| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border | ✅ | 95% | 1 | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 | @@ -63,6 +63,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-12 | trivial | Web: a pasted picture inserts only when the page is the edit target (page focused; no dialog, Backstage or text field such as a comment or Find box taking input), once; otherwise it is ignored (#1349) | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | +| 2026-10-12 | trivial | Tables on the page: a double-click on a nested table's column border fits the column within the cell around the table; a selection from text into or across a table shades the whole cells of the rows it takes (#971) | | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | | 2026-10-11 | trivial | Tables on the page: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells, as in Word | | 2026-10-11 | trivial | Tables on the page: selection areas for columns, rows and cells, "+" insert buttons, the move handle and double-click AutoFit; Select Column in the ribbon | From b34a8744d09d14c8bc8f3ba4ac8c46ce0e7f4034 Mon Sep 17 00:00:00 2001 From: Yousef Al-Harbi Date: Wed, 14 Oct 2026 22:09:09 +0300 Subject: [PATCH 098/199] Reset table tools on table deletion and preserve Draw Table outside tables (#1006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user deleted a table while Draw Table or Eraser was active, the table was removed and the contextual Table Layout tab closed, but the canvas remained trapped in drawing/erasing mode because `table_pen::stop` was never called. - UI: In `WordApp::after_command`, stop active table tools if `table.deleteTable` runs, or if `TableTool::Erase` is active and the caret is no longer in a table cell. - Parity: Keeps `TableTool::Draw` active outside tables so users can still draw new tables from Insert › Tables. - Lifecycle: Stop active table tools in `WordApp::execute` when the document is replaced. - Tests: Added regression tests in `crates/ui-egui/src/table_pen.rs` verifying that table deletion (explicit or via last row) resets the tool. --- crates/ui-egui/src/lib.rs | 6 ++++++ crates/ui-egui/src/table_pen.rs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c172661c..30250b509 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -846,6 +846,7 @@ impl WordApp { if matches!(self.dialog, Some(dialogs::Dialog::SaveChanges { .. })) { self.dialog = None; } + crate::table_pen::stop(self); } // An explicit save to the document's own file lets AutoSave keep writing there. if let Ok(v) = &r @@ -1130,6 +1131,11 @@ impl WordApp { let s = tl!(&s).to_string(); self.status(s); } + if matches!(id, "table.deleteTable") + || (self.canvas.table_tool == Some(crate::table_pen::TableTool::Erase) && self.session.sel.focus.path.cell().is_none()) + { + crate::table_pen::stop(self); + } } /// Requests commands make of the UI (open a dialog…). diff --git a/crates/ui-egui/src/table_pen.rs b/crates/ui-egui/src/table_pen.rs index af0fb2e2e..d3e0d4649 100644 --- a/crates/ui-egui/src/table_pen.rs +++ b/crates/ui-egui/src/table_pen.rs @@ -202,4 +202,22 @@ mod tests { assert!(a.run("table.draw", json!({"page": 0, "x0": 1, "y0": 1, "x1": 2, "y1": 2})).is_err()); assert!(a.canvas.table_tool.is_none()); } + + /// Deleting a table or removing its last cells turns off the eraser. + #[test] + fn deleting_table_turns_off_the_eraser() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + a.run("insert.table", json!({"rows": 2, "cols": 2})).unwrap(); + a.run("table.eraser", json!({"on": true})).unwrap(); + assert_eq!(a.canvas.table_tool, Some(TableTool::Erase)); + a.run("table.deleteTable", json!({})).unwrap(); + assert_eq!(a.canvas.table_tool, None); + + // Deleting the only row also removes the table and stops the eraser. + a.run("insert.table", json!({"rows": 1, "cols": 1})).unwrap(); + a.run("table.eraser", json!({"on": true})).unwrap(); + assert_eq!(a.canvas.table_tool, Some(TableTool::Erase)); + a.run("table.deleteRow", json!({})).unwrap(); + assert_eq!(a.canvas.table_tool, None); + } } From 163462338e7d8794b2bd7240a1f63e10101340ab Mon Sep 17 00:00:00 2001 From: Navid Zaman Khan Date: Thu, 15 Oct 2026 01:09:21 +0600 Subject: [PATCH 099/199] fix(markdown): preserve table cells containing code spans and pipes (#1009) --- crates/formats/src/markdown.rs | 237 +++++++++++++++++++++++++++++++-- 1 file changed, 224 insertions(+), 13 deletions(-) diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 1476c6b0b..29afb1aff 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -165,22 +165,30 @@ fn list_marker(t: &str) -> Option { fn split_row(l: &str) -> Vec { let t = l.trim(); let t = t.strip_prefix('|').unwrap_or(t); - let t = if t.ends_with('|') && !t.ends_with("\\|") { &t[..t.len() - 1] } else { t }; + let t = if let Some(before) = t.strip_suffix('|') { + let num_bs = before.chars().rev().take_while(|&c| c == '\\').count(); + if num_bs % 2 == 0 { before } else { t } + } else { + t + }; let mut cells = Vec::new(); let mut cur = String::new(); let mut chars = t.chars().peekable(); - let mut in_code = false; while let Some(c) = chars.next() { match c { - '\\' if chars.peek() == Some(&'|') => { - cur.push('|'); - chars.next(); - } - '`' => { - in_code = !in_code; - cur.push(c); - } - '|' if !in_code => cells.push(std::mem::take(&mut cur).trim().to_string()), + '\\' => match chars.peek() { + Some(&'|') => { + cur.push('|'); + chars.next(); + } + Some(&'\\') => { + cur.push('\\'); + cur.push('\\'); + chars.next(); + } + _ => cur.push('\\'), + }, + '|' => cells.push(std::mem::take(&mut cur).trim().to_string()), _ => cur.push(c), } } @@ -1084,6 +1092,14 @@ enum Mark { /// Inline spans → Markdown with properly nested markers. fn inlines_md(inlines: &[Inline]) -> String { + inlines_md_impl(inlines, false) +} + +fn inlines_md_table(inlines: &[Inline]) -> String { + inlines_md_impl(inlines, true) +} + +fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { // Split into (text, fmt) pieces with whitespace moved outside emphasis. let mut pieces: Vec<(String, Fmt, Option)> = Vec::new(); let inlines = crate::model::equations_as_text(inlines); @@ -1164,7 +1180,12 @@ fn inlines_md(inlines: &[Inline]) -> String { } } if f.code { - out.push_str(&code_span(&text)); + if in_table { + let t = text.replace('|', "\\|"); + out.push_str(&code_span(&t)); + } else { + out.push_str(&code_span(&text)); + } } else { out.push_str(&escape(&text).replace('\n', "\\\n").replace('\t', " ")); } @@ -1194,7 +1215,7 @@ fn table_md(t: &FTable, out: &mut Vec) { let mut parts = Vec::new(); for b in &c.blocks { match b { - FBlock::Para(p) => parts.push(inlines_md(&p.inlines).replace("\\\n", "
")), + FBlock::Para(p) => parts.push(inlines_md_table(&p.inlines).replace("\\\n", "
")), FBlock::Table(inner) => parts.push(escape(&inner.rows.iter().flat_map(|r| r.iter().map(Cell::text)).collect::>().join(" "))), } } @@ -1424,4 +1445,194 @@ mod tests { assert_eq!(q.text(), "1. not a list *really*"); assert!(q.list.is_none()); } + + #[test] + fn single_backtick_code_span_escaped_pipe() { + let md = "| Command | Status |\n| --- | --- |\n| `cmd \\| grep` | pass |\n"; + let flow = parse(md); + let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") }; + assert_eq!(t.rows.len(), 2); + assert_eq!(t.rows[1].len(), 2); + let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() }; + assert_eq!(p0.inlines.len(), 1); + assert!(matches!(&p0.inlines[0], Inline::Text(t, f) if t == "cmd | grep" && f.code)); + let FBlock::Para(p1) = &t.rows[1][1].blocks[0] else { panic!() }; + assert_eq!(p1.text(), "pass"); + + let mut out = Vec::new(); + table_md(t, &mut out); + let exported = out.join("\n"); + assert!(exported.contains("`cmd \\| grep`"), "Exported: {exported}"); + } + + #[test] + fn double_backtick_code_span_escaped_pipe() { + let md = "| Syntax | Note |\n| --- | --- |\n| ``a \\| b`` | second |\n"; + let flow = parse(md); + let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") }; + assert_eq!(t.rows.len(), 2); + assert_eq!(t.rows[1].len(), 2); + let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() }; + assert_eq!(p0.inlines.len(), 1); + assert!(matches!(&p0.inlines[0], Inline::Text(t, f) if t == "a | b" && f.code)); + let FBlock::Para(p1) = &t.rows[1][1].blocks[0] else { panic!() }; + assert_eq!(p1.text(), "second"); + } + + #[test] + fn code_span_literal_backtick_and_escaped_pipe() { + let md = "| Expression | Detail |\n| --- | --- |\n| ``a`b\\|c`` | distinct |\n"; + let flow = parse(md); + let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") }; + assert_eq!(t.rows.len(), 2); + assert_eq!(t.rows[1].len(), 2); + let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() }; + assert_eq!(p0.inlines.len(), 1); + assert!(matches!(&p0.inlines[0], Inline::Text(t, f) if t == "a`b|c" && f.code)); + let FBlock::Para(p1) = &t.rows[1][1].blocks[0] else { panic!() }; + assert_eq!(p1.text(), "distinct"); + } + + #[test] + fn three_column_table_with_distinctive_text() { + let md = "| Col1 | Col2 | Col3 |\n| --- | --- | --- |\n| `p1\\|q1` | Distinctive Two | Distinctive Three |\n"; + let flow = parse(md); + let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") }; + assert_eq!(t.rows.len(), 2); + assert_eq!(t.rows[1].len(), 3); + assert_eq!(t.rows[1][0].text(), "p1|q1"); + assert_eq!(t.rows[1][1].text(), "Distinctive Two"); + assert_eq!(t.rows[1][2].text(), "Distinctive Three"); + let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() }; + assert!(matches!(&p0.inlines[0], Inline::Text(_, f) if f.code)); + let FBlock::Para(p1) = &t.rows[1][1].blocks[0] else { panic!() }; + assert!(matches!(&p1.inlines[0], Inline::Text(_, f) if !f.code)); + let FBlock::Para(p2) = &t.rows[1][2].blocks[0] else { panic!() }; + assert!(matches!(&p2.inlines[0], Inline::Text(_, f) if !f.code)); + } + + #[test] + fn regular_table_cells_containing_escaped_pipes() { + let md = "| Option | Description |\n| --- | --- |\n| Regular \\| Pipe | Normal text cell |\n"; + let flow = parse(md); + let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") }; + assert_eq!(t.rows.len(), 2); + assert_eq!(t.rows[1].len(), 2); + assert_eq!(t.rows[1][0].text(), "Regular | Pipe"); + assert_eq!(t.rows[1][1].text(), "Normal text cell"); + let FBlock::Para(p0) = &t.rows[1][0].blocks[0] else { panic!() }; + assert!(matches!(&p0.inlines[0], Inline::Text(_, f) if !f.code)); + + let mut out = Vec::new(); + table_md(t, &mut out); + let exported = out.join("\n"); + assert!(exported.contains("Regular \\| Pipe"), "Exported: {exported}"); + assert!(!exported.contains("Regular \\\\| Pipe"), "Exported: {exported}"); + } + + #[test] + fn unescaped_pipes_behave_as_gfm_column_separators() { + let md = "| Header 1 | Header 2 |\n| --- | --- |\n| `raw|pipe` | Extra Column |\n"; + let flow = parse(md); + let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") }; + assert_eq!(t.rows.len(), 2); + assert_eq!(t.rows[1].len(), 2); + assert_eq!(t.rows[1][0].text(), "`raw"); + assert_eq!(t.rows[1][1].text(), "pipe`"); + } + + #[test] + fn ordinary_tables_and_standalone_code_spans_unchanged() { + let md = "| Alpha | Beta |\n| --- | --- |\n| 1 | 2 |\n"; + let flow = parse(md); + let FBlock::Table(t) = &flow.blocks[0] else { panic!() }; + let mut out = Vec::new(); + table_md(t, &mut out); + assert_eq!(out.join("\n"), "| Alpha | Beta |\n| --- | --- |\n| 1 | 2 |"); + + let mut p = Para::default(); + let f = Fmt { code: true, ..Default::default() }; + p.push_text("cmd | grep", &f); + let exported = inlines_md(&p.inlines); + assert_eq!(exported, "`cmd | grep`"); + } + + #[test] + fn table_code_pipe_export_import_round_trip() { + let mut table = FTable::default(); + let mut cell_a = Cell::default(); + let mut para_a = Para::default(); + let fmt_a = Fmt { code: true, ..Default::default() }; + para_a.push_text("a`b|c", &fmt_a); + cell_a.blocks.push(FBlock::Para(para_a)); + + let mut cell_b = Cell::default(); + let mut para_b = Para::default(); + para_b.push_text("CRITICAL_DISTINCTIVE_INFO", &Fmt::default()); + cell_b.blocks.push(FBlock::Para(para_b)); + + let mut head_a = Cell::default(); + let mut head_para_a = Para::default(); + head_para_a.push_text("Col A", &Fmt::default()); + head_a.blocks.push(FBlock::Para(head_para_a)); + + let mut head_b = Cell::default(); + let mut head_para_b = Para::default(); + head_para_b.push_text("Col B", &Fmt::default()); + head_b.blocks.push(FBlock::Para(head_para_b)); + + table.rows.push(vec![head_a, head_b]); + table.rows.push(vec![cell_a, cell_b]); + + let mut out = Vec::new(); + table_md(&table, &mut out); + let exported_md = out.join("\n"); + + assert!(exported_md.contains("``a`b\\|c``"), "Exported: {exported_md}"); + assert!(exported_md.contains("CRITICAL\\_DISTINCTIVE\\_INFO"), "Exported: {exported_md}"); + + let back_flow = parse(&exported_md); + let FBlock::Table(back_table) = &back_flow.blocks[0] else { panic!("expected table") }; + + assert_eq!(back_table.rows.len(), 2, "Both rows must exist"); + let row1 = &back_table.rows[1]; + assert_eq!(row1.len(), 2, "Both cells must exist in row 1"); + + let back_cell_a = &row1[0]; + let back_cell_b = &row1[1]; + assert_eq!(back_cell_a.text(), "a`b|c", "Cell A text must be preserved"); + assert_eq!(back_cell_b.text(), "CRITICAL_DISTINCTIVE_INFO", "Cell B text must be preserved"); + + let FBlock::Para(back_p_a) = &back_cell_a.blocks[0] else { panic!() }; + assert_eq!(back_p_a.inlines.len(), 1); + assert!( + matches!(&back_p_a.inlines[0], Inline::Text(t, f) if t == "a`b|c" && f.code), + "Code formatting must remain intact on Cell A: {:?}", + back_p_a.inlines + ); + + let FBlock::Para(back_p_b) = &back_cell_b.blocks[0] else { panic!() }; + assert_eq!(back_p_b.inlines.len(), 1); + assert!( + matches!(&back_p_b.inlines[0], Inline::Text(t, f) if t == "CRITICAL_DISTINCTIVE_INFO" && !f.code), + "Cell B formatting must remain plain text: {:?}", + back_p_b.inlines + ); + + assert_eq!(back_cell_a.text(), "a`b|c"); + assert_eq!(back_cell_b.text(), "CRITICAL_DISTINCTIVE_INFO"); + + assert!(!back_cell_a.text().contains("CRITICAL_DISTINCTIVE_INFO")); + assert!(!back_cell_b.text().contains("a`b|c")); + + let doc = import(&exported_md); + let re_exported = export(&doc); + assert!(re_exported.contains("``a`b\\|c``"), "Re-exported: {re_exported}"); + assert!(re_exported.contains("CRITICAL\\_DISTINCTIVE\\_INFO"), "Re-exported: {re_exported}"); + let doc2 = import(&re_exported); + let back_flow2 = model::from_doc(&doc2); + let FBlock::Table(back_table2) = &back_flow2.blocks[0] else { panic!() }; + assert_eq!(back_table2.rows[1][0].text(), "a`b|c"); + assert_eq!(back_table2.rows[1][1].text(), "CRITICAL_DISTINCTIVE_INFO"); + } } From ee33d71173c856e5ffc6850c1a6eb23d2048041d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:09:32 +0200 Subject: [PATCH 100/199] Focus hides the ribbon, status bar and panes; Escape brings them back (#1017) --- crates/engine/src/cmd/tools.rs | 1 - crates/ui-egui/src/chrome.rs | 1 + crates/ui-egui/src/lib.rs | 53 ++++++++++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 8b5c9f9b5..3f4dcf2d5 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -659,7 +659,6 @@ pub fn specs() -> Vec { }), CommandSpec::new("view.immersive", "Immersive Reader", "View › Immersive", |s, _| { s.view.read_mode = !s.view.read_mode; - s.view.focus_mode = s.view.read_mode; Ok(json!({"value": s.view.read_mode})) }) .pure(), diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 87a73a0ac..c89f53266 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -523,6 +523,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { ftrack, t.text_dim, ); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, focus)); if resp.clicked() { let _ = app.run("view.focus", json!({})); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 30250b509..68bdfc19b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1803,6 +1803,11 @@ impl WordApp { // Keytips are read before layout so the frame paints the new state; badges paint after // the ribbon so they sit on top of it. crate::keytips::logic(self, &ctx); + // Focus (View › Immersive, the status bar) keeps only the title bar and the page; Escape + // brings the rest back, as in Word. + if self.session.view.focus_mode && self.dialog.is_none() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + let _ = self.run("view.focus", json!({"value": false})); + } if self.ui.backstage { backstage::show(self, ui); } else if !self.has_document() { @@ -1813,9 +1818,11 @@ impl WordApp { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |_| {}); } else { chrome::title_bar(self, ui); - ribbon::show(self, ui); - chrome::status_bar(self, ui); - panes::show(self, ui); + if !self.session.view.focus_mode { + ribbon::show(self, ui); + chrome::status_bar(self, ui); + panes::show(self, ui); + } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { canvas::show(self, ui); }); @@ -5654,4 +5661,44 @@ mod tests { h.step(); assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("find")); } + + /// #991: the status bar's Focus button leaves only the page (no ribbon, status bar or panes); + /// Escape brings them back. + #[test] + fn focus_hides_the_ribbon_and_escape_brings_it_back() { + use egui_kittest::kittest::Queryable; + let mut a = app(); + a.ui.tab = "View".into(); + a.session.view.nav_pane = true; + 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 shown = |h: &egui_kittest::Harness, label| h.query_by_label(label).is_some(); + assert!(shown(&h, "Ruler"), "the View tab is showing"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Focus").click(); + h.step(); + h.step(); + assert!(h.state().session.view.focus_mode); + assert!(!shown(&h, "Ruler") && !shown(&h, "Focus"), "the ribbon is hidden"); + assert!(!shown(&h, "Navigation"), "and so is the Navigation pane"); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.step(); + h.step(); + assert!(!h.state().session.view.focus_mode, "Escape leaves Focus"); + assert!(shown(&h, "Ruler"), "and the ribbon is back"); + assert!(shown(&h, "Navigation"), "with the pane"); + // Immersive Reader is Read Mode, not Focus: its status bar stays. + h.state_mut().run("view.immersive", json!({})).unwrap(); + h.step(); + h.step(); + assert!(h.state().session.view.read_mode && shown(&h, "Focus"), "Immersive Reader keeps the status bar"); + } } From 6be7ef10fb555f2d312f7695bf550e9415d5d121 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:30:07 +0200 Subject: [PATCH 101/199] =?UTF-8?q?Ribbon=20tabs=20that=20don't=20fit=20mo?= =?UTF-8?q?ve=20to=20a=20=C2=BB=20menu=20instead=20of=20under=20Comments?= =?UTF-8?q?=20(#958)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Brandon Thomas --- crates/ui-egui/src/ribbon.rs | 165 +++++++++++++++++++++++++++------- crates/ui-egui/src/widgets.rs | 1 + 2 files changed, 136 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 68451f4c5..1f053154f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -135,6 +135,30 @@ pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str { if available.contains(¤t) { current } else { "Home" } } +/// Width of the » button that holds the tabs the strip has no room for. +const MORE_TABS_W: f32 = 24.0; + +/// Which tabs fit on a strip `avail` points wide (#897), from each tab's width and whether it must +/// stay. The others give way from the end, leaving `more` points for the » menu that holds them +/// (pure, tested). +pub fn fit_tabs(tabs: &[(f32, bool)], avail: f32, more: f32) -> Vec { + let mut fits = vec![true; tabs.len()]; + let mut total: f32 = tabs.iter().map(|(w, _)| w).sum(); + if total <= avail { + return fits; + } + for (fit, (w, keep)) in fits.iter_mut().zip(tabs).rev() { + if total + more <= avail { + break; + } + if !keep { + *fit = false; + total -= w; + } + } + fits +} + /// Whether the caret is inside a table (so the contextual tabs and their badges are shown). pub fn in_table_public(app: &WordApp) -> bool { app.session.sel.focus.path.cell().is_some() @@ -196,41 +220,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 0.0); - for (tab, custom) in &tabs { - let (tab, custom) = (tab.as_str(), *custom); - let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab); - // A custom tab's name is the user's own text: shown as typed. - let shown = if custom { tab } else { tl!(tab) }; - let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0; - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); - if app.ui.keytips == crate::keytips::Phase::Tabs && app.ui.keytip_prefix.is_empty() { - crate::keytips::record_tab(r, tab, &mut app.keytip_rects); - } - let active = app.ui.tab == tab && !app.ui.backstage; - if resp.hovered() && !active { - ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); - } - let color = if contextual || active { t.accent_text } else { t.text }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, if active { semibold(12.5) } else { medium(12.5) }, color); - if active { - let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0)); - ui.painter().rect_filled(u, 2.0, t.accent); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, shown)); - if resp.clicked() { - let _ = app.run("ui.tab", json!({"tab": tab})); - } - if resp.double_clicked() && tab != "File" { - let _ = app.run("ui.collapseRibbon", json!({})); - } - resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); - } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // No document open (#1129): nothing to share, edit or comment on. if !app.has_document() { ui.disable(); } - // Share, Editing mode, Comments. + // Share, Editing mode, Comments, drawn first so the tabs get only the room + // left of them (#897). let share = tl!("Share"); let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x); let (r, resp) = ui.allocate_exact_size(vec2((share_w + 36.0).max(74.0), 24.0), Sense::click()); @@ -264,6 +260,73 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if crate::widgets::icon_text_button(ui, "comment", tl!("Comments"), regular(12.0)).clicked() { let _ = app.run("view.commentsPane", json!({})); } + ui.add_space(8.0); + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + // The tabs that don't fit go into a » menu at the end of the strip. File, the + // active tab and the contextual tabs (appended last) stay on the strip (#897). + let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect(); + let widths: Vec<(f32, bool)> = tabs + .iter() + .zip(&labels) + .map(|((tab, custom), shown)| { + let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0; + let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str()); + (w + 2.0, tab == "File" || *tab == app.ui.tab || contextual) + }) + .collect(); + let fits = fit_tabs(&widths, ui.available_width(), MORE_TABS_W + 2.0); + for ((tab, custom), _) in tabs.iter().zip(&fits).filter(|(_, fits)| **fits) { + let (tab, custom) = (tab.as_str(), *custom); + let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab); + // A custom tab's name is the user's own text: shown as typed. + let shown = if custom { tab } else { tl!(tab) }; + let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0; + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + if app.ui.keytips == crate::keytips::Phase::Tabs && app.ui.keytip_prefix.is_empty() { + crate::keytips::record_tab(r, tab, &mut app.keytip_rects); + } + let active = app.ui.tab == tab && !app.ui.backstage; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, shown)); + if resp.hovered() && !active { + ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); + } + let color = if contextual || active { t.accent_text } else { t.text }; + ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, if active { semibold(12.5) } else { medium(12.5) }, color); + if active { + let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0)); + ui.painter().rect_filled(u, 2.0, t.accent); + } + if resp.clicked() { + let _ = app.run("ui.tab", json!({"tab": tab})); + } + if resp.double_clicked() && tab != "File" { + let _ = app.run("ui.collapseRibbon", json!({})); + } + resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); + } + let left_out: Vec<(&str, &str)> = tabs + .iter() + .zip(&labels) + .zip(&fits) + .filter(|(_, fits)| !**fits) + .map(|(((tab, _), shown), _)| (tab.as_str(), *shown)) + .collect(); + if !left_out.is_empty() { + let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); + } + ui.painter().text(r.center(), Align2::CENTER_CENTER, "»", medium(12.5), t.text); + egui::Popup::menu(&resp).show(|ui| { + for (tab, shown) in left_out { + if ui.selectable_label(false, shown).clicked() { + let _ = app.run("ui.tab", json!({"tab": tab})); + ui.close(); + } + } + }); + } + }); }); }); }); @@ -2772,6 +2835,48 @@ mod tests { assert!(tall.is_empty(), "groups taller than the ribbon: {tall:#?}"); } + /// #897: the tabs that didn't fit were drawn under Comments / Editing / Share, so at 1280 pt + /// in Polish the table tabs were out of reach. They stay on the strip and a click opens them. + #[test] + fn table_tabs_stay_clickable_when_the_tab_strip_overflows() { + use egui_kittest::kittest::Queryable; + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Text")), crate::Services::default()); + app.run("insert.table", json!({"rows": 3, "cols": 3})).unwrap(); + app.run("ui.language", json!({"value": "pl"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let buttons = [tl!("Comments"), tl!("Editing")].map(|b| h.get_by_label(b).rect()); + for tab in ["Table Design", "Table Layout"] { + let r = h.get_by_label(tl!(tab)).rect(); + assert!(r.right() <= 1280.0 && buttons.iter().all(|b| !b.intersects(r)), "{tab} {r:?} under {buttons:?}"); + h.get_by_label(tl!(tab)).click(); + h.run_steps(2); + assert_eq!(h.state().ui.tab, tab); + } + } + + #[test] + fn fit_tabs_keeps_the_kept_tabs_and_drops_from_the_end() { + let tabs = [(40.0, true), (50.0, false), (50.0, false), (50.0, false), (60.0, true)]; + assert_eq!(fit_tabs(&tabs, 250.0, 20.0), [true; 5]); + // 250 doesn't fit 240 with room for »: the last plain tabs give way, the kept ones stay. + assert_eq!(fit_tabs(&tabs, 240.0, 20.0), [true, true, true, false, true]); + // 200 would fit 210, but not with the » button too. + assert_eq!(fit_tabs(&tabs, 210.0, 20.0), [true, true, false, false, true]); + assert_eq!(fit_tabs(&tabs, 150.0, 20.0), [true, false, false, false, true]); + // Nothing but the kept tabs fits: they stay anyway. + assert_eq!(fit_tabs(&tabs, 10.0, 20.0), [true, false, false, false, true]); + // Hostile numbers don't panic. + let _ = fit_tabs(&[(f32::NAN, false), (f32::INFINITY, false)], f32::NAN, 20.0); + } + /// #860: Review › Language (and the status bar) reach every proofing language, not just the /// short list: Set Proofing Language… opens the Language dialog (French, Set As Default), /// Language Preferences… opens Options › Language, and French text shows by name, checked. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ad4adcdd3..3c916dffa 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -826,6 +826,7 @@ fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId let marker_w = if marker { DROPDOWN + 4.0 } else { 0.0 }; let size = vec2(pad.x + icon_w + 4.0 + galley.size().x + marker_w + pad.x, galley.size().y.max(icon_w) + 2.0 * pad.y); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); let st = drawn_state(ui, &resp, true); let r = r.expand(st.expansion); ui.painter().rect(r, ui.visuals().widgets.inactive.corner_radius, st.fill, st.stroke, egui::StrokeKind::Inside); From 90762b052902582324117ea0fd0b898ed08455ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Lo=C5=A1=C3=A1k?= Date: Wed, 14 Oct 2026 21:30:20 +0200 Subject: [PATCH 102/199] i18n(cs): wording fixes after #387 (#973) * i18n(cs): wording fixes after #387 SmartArt graphic and layout terms consistent with the rest of the catalog, inline equation, plural-safe print count, grammar terminology (mluvnice), tab stop type, file-not-found gender, review author label. Also adds the [people.Losminek] contributor entry. Co-Authored-By: Claude Opus 5.5 * i18n(cs): shorter Proofing label "Kontrola pravopisu a mluvnice" overflows the fixed 170 px Options pane list into the content. "Kontrola pravopisu" fits and also reads well as the Review ribbon group name. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 +++ crates/ui-egui/src/i18n/cs.tsv | 42 +++++++++++++++++----------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index cc51ccaaa..8bf8fc61c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -82,6 +82,9 @@ display_name = "cyril265" display_name = "Jason Brashear" sync_github_name = false + +[people.Losminek] +sync_github_name = true [people.benandez] real_name = "Martin Ball" display_name = "Martin Ball" diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5035211a1..a9add49f6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -173,7 +173,7 @@ Change Shape Změnit tvar Change Structure Změnit strukturu Change to Display Změnit na samostatnou rovnici - Change to Inline Změnit na vnořenou rovnici + Change to Inline Změnit na rovnici v řádku Changed lines: Změněné řádky: Changes Změny Character Znak @@ -276,7 +276,7 @@ Date & Time Datum a čas Date and Time Datum a čas Date formats: Formáty data: - Decimal Desetinná čárka + Decimal Desetinné Decrease Font Size Zmenšit velikost písma Decrease Indent Zmenšit odsazení Default Výchozí @@ -529,7 +529,7 @@ Ink to\nMath Rukopis na\nrovnici Ink to\nShape Rukopis na\ntvar Ink\nReplay Přehrát\nrukopis - Inline Vnořená + Inline V řádku Input Format Formát vstupu Insert Vložení Insert a new equation (Alt+=) Vložit novou rovnici (Alt+=) @@ -850,7 +850,7 @@ Print Layout Rozvržení při tisku Print\nLayout Rozvržení\npři tisku Professional Profesionální - Proofing Kontrola pravopisu a mluvnice + Proofing Kontrola pravopisu Proofing Issues Nalezené chyby Properties Vlastnosti Protect Zabezpečení @@ -1083,7 +1083,7 @@ Strikethrough Přeškrtnuté Structure Actions Akce se strukturou Structures Struktury - Studio cover Titulní stránka studia + Studio cover Titulní stránka Studio Studio handbook (sample) Příručka studia (ukázka) Style Styl Style based on: Styl založený na: @@ -1262,7 +1262,7 @@ Zotero: done Zotero: hotovo {count} items highlighted Počet zvýrazněných položek: {count} {group} settings Nastavení skupiny {group} - {key}+Click to follow link {key}+kliknutí pro otevření odkazu + {key}+Click to follow link {key}+kliknutím otevřete odkaz {name} is protected with a password. Dokument {name} je chráněn heslem. {n} lines Řádků: {n} {n} pt {n} b. @@ -1326,8 +1326,8 @@ Size and Crop… Velikost a oříznutí… Insert Columns to the Left Vložit sloupce vlevo Insert Columns to the Right Vložit sloupce vpravo - Insert Rows Above Vložit řádky výše - Insert Rows Below Vložit řádky níže + Insert Rows Above Vložit řádky nad + Insert Rows Below Vložit řádky pod Mon Po Tue Út Wed St @@ -1632,7 +1632,7 @@ Danish Dánština Delivery address: Dodací adresa: Detect language automatically Rozpoznat jazyk automaticky - Do not check spelling or grammar Nekontrolovat pravopis ani gramatiku + Do not check spelling or grammar Nekontrolovat pravopis ani mluvnici Dutch Nizozemština English (United Kingdom) Angličtina (Spojené království) Envelope size: Velikost obálky: @@ -1703,7 +1703,7 @@ Checked styles are allowed: Povolené jsou zaškrtnuté styly: Combine Documents Zkombinovat dokumenty Compare Documents Porovnat dokumenty - Compared: {changes} changes Porovnáno: počet změn: {changes} + Compared: {changes} changes Porovnáno – počet změn: {changes} Comparing with another file needs the desktop app. Porovnání s jiným souborem vyžaduje desktopovou aplikaci. Comparison settings Nastavení porovnání Don't capitalize after: Nepsat velké písmeno po: @@ -1722,7 +1722,7 @@ Hyphens (--) as dashes (—) Spojovníky (--) jako pomlčky (—) In equations, typing a name after a backslash replaces it with its symbol. V rovnicích se název napsaný za zpětným lomítkem nahradí příslušným symbolem. Internet addresses as links Internetové adresy jako odkazy - Label changes with: Označit změny štítkem: + Label changes with: Označit změny jménem: Limit formatting to a selection of styles Omezit formátování na vybrané styly Math AutoCorrect Automatické opravy matematiky More >> Více >> @@ -1846,7 +1846,7 @@ Interface language Jazyk rozhraní Inside: Uvnitř: Latin text font: Písmo pro latinkový text: - Mark grammar errors as you type Označovat gramatické chyby při psaní + Mark grammar errors as you type Označovat mluvnické chyby při psaní Measurement units: Jednotky měření: Millimeters Milimetry Mirror indents Zrcadlové odsazení @@ -1928,8 +1928,8 @@ The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Systémový dialog pro práci se soubory není k dispozici, proto WordCraft zobrazuje vlastní. Chcete-li používat systémový dialog, nainstalujte portál pro výběr souborů (například xdg-desktop-portal-gtk) nebo zenity. Up one level O úroveň výš Home folder Domovská složka - {name} wasn't found. {name} nebyl nalezen. - {name} already exists. Choose Save again to replace it. {name} již existuje. Dalším zvolením Uložit jej nahradíte. + {name} wasn't found. Položka {name} nebyla nalezena. + {name} already exists. Choose Save again to replace it. Položka {name} již existuje. Dalším zvolením Uložit ji nahradíte. This folder can't be read: {error} Tuto složku nelze přečíst: {error} Showing the first {count} items. Zobrazuje se prvních {count} položek. File name: Název souboru: @@ -1946,18 +1946,18 @@ Copies Kopie Collate copies Kompletovat kopie Pages like 1-3, 5 Stránky, např. 1-3, 5 - {count} of {total} pages will print Vytiskne se {count} z {total} stránek + {count} of {total} pages will print Stránky k tisku: {count} z {total} Save these pages as PDF… Uložit tyto stránky jako PDF… The printer, two-sided printing and pages per sheet are chosen in that dialog. Tiskárna, oboustranný tisk a počet stránek na list se volí v tomto dialogu. Previous Page Předchozí stránka Print to PDF Tisk do PDF Add Shape Přidat tvar Change Colors Změnit barvy - Change Layout Změnit rozložení - Create Graphic Vytvořit obrázek + Change Layout Změnit rozvržení + Create Graphic Vytvořit grafiku Edit Items Upravit položky - Layouts Rozložení - Reset Graphic Obnovit obrázek + Layouts Rozvržení + Reset Graphic Obnovit grafiku SmartArt Design Návrh SmartArt SmartArt Styles Styly SmartArt Text Pane Podokno textu @@ -1988,7 +1988,7 @@ One line per item. Enter adds an item. Jeden řádek na položku. Klávesa Enter přidá položku. Add\nShape Přidat\ntvar Change\nColors Změnit\nbarvy - Reset\nGraphic Obnovit\nobrázek + Reset\nGraphic Obnovit\ngrafiku Promote to Heading 1 Povýšit na Nadpis 1 Demote to Body Text Snížit na základní text Move Up Přesunout nahoru @@ -2056,7 +2056,7 @@ Your system has animation effects turned on. Efekty animace jsou v systému zapnuté. Your system has animation effects turned off. Efekty animace jsou v systému vypnuté. Your system doesn't report an animation setting, so animation effects stay on. Systém neuvádí žádné nastavení animací, proto efekty animace zůstávají zapnuté. - Do Not Check Spelling or Grammar Nekontrolovat pravopis ani gramatiku + Do Not Check Spelling or Grammar Nekontrolovat pravopis ani mluvnici Undo Actions Vrátit více akcí Undo History Historie vrácení Undo 1 Action Vrátit 1 akci From 55973512132bec506a2857d40ab3e9c896a0ae9c Mon Sep 17 00:00:00 2001 From: Justin John <34035011+justinjohn0306@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:00:32 +0530 Subject: [PATCH 103/199] #916: Cross-reference opens a dialog (#1003) * #916: Cross-reference opens a dialog The ribbon's Cross-reference button ran insert.crossReference with no target, which only returns the target list, so nothing happened. It now opens a Cross-reference dialog: reference type (heading, bookmark, equation, figure, table), what to insert (text, number, page, above/below) and the item, ending in insert.crossReference. Scripts and agents still get the target list. Co-Authored-By: Claude Opus 5.5 * i18n: Cross-reference dialog strings in the audited catalogs (ru, hu, tr, bg) Co-Authored-By: Claude Opus 5.5 * i18n: Cross-reference dialog strings in pl, it and vi Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: justinjohn0306 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/citations.rs | 4 +- crates/ui-egui/src/dialogs_refs.rs | 126 +++++++++++++++++++++++++++-- crates/ui-egui/src/i18n/bg.tsv | 7 ++ crates/ui-egui/src/i18n/hu.tsv | 7 ++ crates/ui-egui/src/i18n/it.tsv | 7 ++ crates/ui-egui/src/i18n/pl.tsv | 7 ++ crates/ui-egui/src/i18n/ru.tsv | 7 ++ crates/ui-egui/src/i18n/tr.tsv | 7 ++ crates/ui-egui/src/i18n/vi.tsv | 7 ++ crates/ui-egui/src/lib.rs | 2 + docs/ui-parity.md | 5 +- 11 files changed, 175 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index 68fc82a63..795854370 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -342,7 +342,7 @@ pub fn specs() -> Vec { sel_result(s) }), CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref) - .params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#), + .params(r#"{"to": "heading|bookmark|figure|table|equation", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#), CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params( r#"{"kind"?: "footnote|endnote", "pos"?: "pageBottom|beneathText" (footnotes) | "sectEnd|docEnd" (endnotes), "numFmt"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter|…", "numStart"?: 1-32767, "numRestart"?: "continuous|eachSect|eachPage" (each page: footnotes only), "scope"?: "document|section", "footnoteFormat"?: …, "endnoteFormat"?: …} — no settings: the options in effect at the caret"#, ), @@ -899,7 +899,7 @@ fn cross_ref(s: &mut Session, v: &Value) -> CmdResult { } /// Cross-reference targets of a kind: (shown text or bookmark name, position). -fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> { +pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> { let mut targets: Vec<(String, Pos)> = Vec::new(); match to { "bookmark" => targets = s.doc.bookmarks(), diff --git a/crates/ui-egui/src/dialogs_refs.rs b/crates/ui-egui/src/dialogs_refs.rs index 6c8803f25..27faed479 100644 --- a/crates/ui-egui/src/dialogs_refs.rs +++ b/crates/ui-egui/src/dialogs_refs.rs @@ -1,9 +1,9 @@ -//! References dialogs (#402): Caption (with New Label and Numbering), Index and Mark Index Entry, -//! Custom Table of Contents (with Options and the TOC styles), and the Source Manager with -//! Create / Edit Source. Each fills a form from the session and ends in a command -//! (`references.caption`, `references.index`, `references.markEntry`, `references.toc`, -//! `references.sources`, `references.citation`), so agents get the same result without the -//! dialog. Shown through [`crate::dialogs::Dialog::Refs`]. +//! References dialogs (#402): Caption (with New Label and Numbering), Cross-reference (#916), +//! Index and Mark Index Entry, Custom Table of Contents (with Options and the TOC styles), and the +//! Source Manager with Create / Edit Source. Each fills a form from the session and ends in a +//! command (`references.caption`, `insert.crossReference`, `references.index`, +//! `references.markEntry`, `references.toc`, `references.sources`, `references.citation`), so +//! agents get the same result without the dialog. Shown through [`crate::dialogs::Dialog::Refs`]. use egui::{Sense, Ui, vec2}; use serde::Serialize; @@ -27,6 +27,7 @@ pub enum RefsDialog { }, /// Caption › New Label…. NewLabel(CaptionForm), + CrossRef(CrossRefForm), Index(IndexForm), MarkEntry(MarkForm), Toc(TocForm), @@ -53,6 +54,7 @@ impl RefsDialog { RefsDialog::Caption(_) => "caption", RefsDialog::CaptionNumbering { .. } => "captionNumbering", RefsDialog::NewLabel(_) => "newLabel", + RefsDialog::CrossRef(_) => "crossReference", RefsDialog::Index(_) => "index", RefsDialog::MarkEntry(_) => "markEntry", RefsDialog::Toc(_) => "toc", @@ -70,6 +72,7 @@ impl RefsDialog { RefsDialog::Caption(_) => "Caption", RefsDialog::CaptionNumbering { .. } => "Caption Numbering", RefsDialog::NewLabel(_) => "New Label", + RefsDialog::CrossRef(_) => "Cross-reference", RefsDialog::Index(_) => "Index", RefsDialog::MarkEntry(_) => "Mark Index Entry", RefsDialog::Toc(_) => "Table of Contents", @@ -82,11 +85,12 @@ impl RefsDialog { } } -/// The dialog `ui.dialog` opens by `name`: `caption`, `index`, `markEntry`, `toc`, +/// The dialog `ui.dialog` opens by `name`: `caption`, `crossReference`, `index`, `markEntry`, `toc`, /// `sourceManager` or `createSource` (which inserts a citation of the new source). pub fn open(name: &str, app: &WordApp) -> Option { Some(match name { "caption" => RefsDialog::Caption(CaptionForm::read(app)), + "crossReference" => RefsDialog::CrossRef(CrossRefForm::read(app, "heading")), "index" => RefsDialog::Index(IndexForm::read(app)), "markEntry" => RefsDialog::MarkEntry(MarkForm::read(app)), "toc" => RefsDialog::Toc(TocForm::read(app)), @@ -102,6 +106,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut RefsDialog) -> bool { RefsDialog::Caption(f) => caption_ui(app, ui, f), RefsDialog::CaptionNumbering { form, was } => numbering_ui(ui, form, was), RefsDialog::NewLabel(f) => new_label_ui(app, ui, f), + RefsDialog::CrossRef(f) => cross_ref_ui(app, ui, f), RefsDialog::Index(f) => index_ui(app, ui, f), RefsDialog::MarkEntry(f) => mark_ui(app, ui, f), RefsDialog::Toc(f) => toc_ui(app, ui, f), @@ -337,6 +342,89 @@ fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next { if ok || cancel { Next::Close } else { Next::Stay } } +// --------------------------------------------------------------------------------------------- +// Cross-reference + +/// Reference types: (`insert.crossReference`'s `to`, label). +const REF_TYPES: [(&str, &str); 5] = + [("heading", "Heading"), ("bookmark", "Bookmark"), ("equation", "Equation"), ("figure", "Figure"), ("table", "Table")]; + +/// The Cross-reference dialog's fields. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CrossRefForm { + /// `heading`, `bookmark`, `equation`, `figure` or `table`. + pub to: String, + /// `text`, `page`, `number` or `aboveBelow`. + pub show: String, + /// The document's targets of this type. + pub targets: Vec, + pub selected: Option, +} + +impl CrossRefForm { + pub fn read(app: &WordApp, to: &str) -> CrossRefForm { + let targets: Vec = wordcraft_engine::cmd::citations::ref_targets(&app.session, to).into_iter().map(|(t, _)| t).collect(); + let selected = (!targets.is_empty()).then_some(0); + CrossRefForm { to: to.into(), show: "text".into(), targets, selected } + } + + /// What can be inserted for this type: (`show`, label). + pub fn shows(&self) -> &'static [(&'static str, &'static str)] { + match self.to.as_str() { + "heading" => &[("text", "Heading text"), ("page", "Page number"), ("aboveBelow", "Above/below")], + "bookmark" => &[("text", "Bookmark text"), ("page", "Page number"), ("aboveBelow", "Above/below")], + _ => &[("text", "Entire caption"), ("number", "Only label and number"), ("page", "Page number"), ("aboveBelow", "Above/below")], + } + } + + /// `insert.crossReference` parameters, or `None` with nothing picked. + pub fn params(&self) -> Option { + let target = self.targets.get(self.selected?)?; + Some(json!({"to": self.to, "target": target, "show": self.show})) + } +} + +fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next { + let was = f.to.clone(); + egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Reference type:")); + let types: Vec<(String, &str)> = REF_TYPES.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "xref_type", &mut f.to, &types); + ui.end_row(); + ui.label(tl!("Insert reference to:")); + let shows: Vec<(String, &str)> = f.shows().iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "xref_show", &mut f.show, &shows); + ui.end_row(); + }); + if f.to != was { + *f = CrossRefForm::read(app, &f.to); + } + ui.add_space(4.0); + ui.label(tl!("For which item:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(180.0).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(320.0); + if f.targets.is_empty() { + ui.weak(tl!("Nothing of this type in the document.")); + } + for (i, t) in f.targets.iter().enumerate() { + if ui.selectable_label(f.selected == Some(i), t).clicked() { + f.selected = Some(i); + } + } + }); + }); + let (ok, cancel) = buttons(ui, tl!("Insert")); + if ok && let Some(params) = f.params() { + if let Err(e) = app.run("insert.crossReference", params) { + app.status(e); + } + return Next::Close; + } + if cancel { Next::Close } else { Next::Stay } +} + fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next { egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label(tl!("Format:")); @@ -1100,6 +1188,30 @@ mod tests { assert_eq!(a.prefs().caption_labels, vec!["Plate".to_string()]); } + /// #916: Cross-reference from the ribbon opens a dialog listing the targets of each type + /// (equation captions too) and inserts a `REF` field to the one picked. + #[test] + fn cross_reference_dialog_lists_targets_and_inserts_a_ref_field() { + let mut a = app("Kilns\nE=mc2\nSee "); + a.run("select.text", json!({"text": "Kilns"})).unwrap(); + a.run("styles.apply", json!({"style": "Heading 1"})).unwrap(); + a.run("select.text", json!({"text": "E=mc2"})).unwrap(); + a.run("references.caption", json!({"label": "Equation", "text": "energy"})).unwrap(); + a.run("caret.docEnd", json!({})).unwrap(); + assert_eq!(a.run("insert.crossReference", json!({})).unwrap(), json!({"pending": "crossReference"})); + let RefsDialog::CrossRef(f) = form(&a) else { panic!() }; + assert_eq!((f.targets.clone(), f.selected), (vec!["Kilns".to_string()], Some(0))); + let mut f = CrossRefForm::read(&a, "equation"); + assert_eq!(f.targets, vec!["Equation 1: energy".to_string()]); + f.show = "number".into(); + a.dialog = None; + a.run("insert.crossReference", f.params().unwrap()).unwrap(); + assert!(instrs(&a).iter().any(|i| i.starts_with("REF _Ref")), "{:?}", instrs(&a)); + assert!(a.session.doc.plain_text(StoryRef::Body).ends_with("See Equation 1")); + // Nothing to pick: nothing to insert. + assert_eq!(CrossRefForm::read(&a, "table").params(), None); + } + /// #402: Mark Entry opens with the selection; Insert Index opens with Word's defaults and /// reads the index back; both write their switches. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 319a15f22..f135f6a0d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2503,3 +2503,10 @@ Suggested Предложения Set Proofing Language… Задаване на езика за проверка… Language Preferences… Езикови предпочитания… + +# Cross-reference dialog (#1003) + For which item: За кой елемент: + Heading Заглавие + Heading text Текст на заглавието + Insert reference to: Вмъкване на препратка към: + Reference type: Тип препратка: diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 33fd08e8c..5d78a83b2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2512,3 +2512,10 @@ Set Proofing Language… Nyelvi ellenőrzés nyelvének beállítása… Language Preferences… Nyelvi beállítások… Title Bar Címsáv + +# Cross-reference dialog (#1003) + For which item: Melyik elemhez: + Heading Címsor + Heading text Címsor szövege + Insert reference to: Hivatkozás beszúrása erre: + Reference type: Hivatkozás típusa: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 878d83292..2189b5720 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2498,3 +2498,10 @@ preview Title Esempio Suggested Suggerimenti Set Proofing Language… Imposta lingua di correzione… Language Preferences… Preferenze lingua… + +# Cross-reference dialog (#1003) + For which item: Per quale elemento: + Heading Titolo + Heading text Testo del titolo + Insert reference to: Inserisci riferimento a: + Reference type: Tipo di riferimento: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 13382375b..548db835e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2507,3 +2507,10 @@ preview Title Tytuł Suggested Sugerowane Set Proofing Language… Ustaw język sprawdzania… Language Preferences… Preferencje językowe… + +# Cross-reference dialog (#1003) + For which item: Dla którego elementu: + Heading Nagłówek + Heading text Tekst nagłówka + Insert reference to: Wstaw odwołanie do: + Reference type: Typ odwołania: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3a26965d5..e2a3a32f4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2513,3 +2513,10 @@ preview Title Пример Suggested Предлагаемые Set Proofing Language… Выбрать язык проверки правописания… Language Preferences… Языковые параметры… + +# Cross-reference dialog (#1003) + For which item: Для какого элемента: + Heading Заголовок + Heading text Текст заголовка + Insert reference to: Вставить ссылку на: + Reference type: Тип ссылки: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 213da13ad..46d687066 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2512,3 +2512,10 @@ preview Title Örnek Suggested Önerilenler Set Proofing Language… Yazım denetimi dilini ayarla… Language Preferences… Dil tercihleri… + +# Cross-reference dialog (#1003) + For which item: Hangi öğe için: + Heading Başlık + Heading text Başlık metni + Insert reference to: Başvuru ekle: + Reference type: Başvuru türü: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3afcf4079..e51c4c276 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2532,3 +2532,10 @@ # Alignment guides (#1519) Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Đối tượng được di chuyển sẽ bắt vào mép và tâm trang, lề và các đối tượng khác. Giữ Alt để di chuyển tự do. Show alignment guides when moving pictures and shapes Hiện đường căn chỉnh khi di chuyển ảnh và hình + +# Cross-reference dialog (#1003) + For which item: Cho mục nào: + Heading Đề mục + Heading text Văn bản đề mục + Insert reference to: Chèn tham chiếu tới: + Reference type: Loại tham chiếu: diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 68bdfc19b..a8bc0d55a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2127,6 +2127,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { Some(if id == "references.caption" { "caption" } else { "index" }) } "references.markEntry" if !has("entry") => Some("markEntry"), + // Cross-reference without a target shows its dialog (#916); scripts get the target list. + "insert.crossReference" if !has("target") => Some("crossReference"), "references.sources" if params.as_object().is_none_or(|m| m.is_empty()) => Some("sourceManager"), "references.citation" if !has("tag") && !has("source") => Some("createSource"), // Templates and Add-ins, and the Organizer without a file to list (#377). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2e8fb94ee..171d962d1 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-14 · **Change:** trivial (web Save Changes prompt waits for the save picker, #1315; previously trivial: AutoFit in a nested table, whole cells shaded for a selection through a table, #971; 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-14 · **Change:** trivial (Cross-reference dialog, #916; previously trivial: web Save Changes prompt waits for the save picker, #1315; previously trivial: AutoFit in a nested table, whole cells shaded for a selection through a table, #971; 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 @@ -17,7 +17,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | -| Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Modal dialogs | **55** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | @@ -49,6 +49,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Cross-reference dialog: reference type (heading, bookmark, equation, figure, table), what to insert and the item; the ribbon button opened nothing before (#916); modal dialog count 55 | | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | From aedbbc93252022f01ec0ce1e844555eaa80ee387 Mon Sep 17 00:00:00 2001 From: Prashant Neupane <106688408+neupaneprashant@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:30:52 -0500 Subject: [PATCH 104/199] feat(engine): rename documents from the title bar and open account settings from the badge (#1016) * M10: enable document renaming and account settings * no-mistakes(review): Allow case-only document renames without overwriting files * no-mistakes(review): Keep unsaved rename as session file name, not Title * no-mistakes(review): Keep AutoSave and Recent across renames; Compare clears name * no-mistakes(document): Document file.rename write-root rule; dedupe rename gap note * vi: Rename Document strings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 +- contributors/people.toml | 3 + crates/engine/src/cmd/file.rs | 31 ++++++++ crates/engine/src/cmd/tools.rs | 1 + crates/engine/src/io.rs | 34 +++++++++ crates/engine/src/session.rs | 6 ++ crates/engine/src/tests.rs | 62 ++++++++++++++++ crates/ui-egui/src/chrome.rs | 26 ++++++- crates/ui-egui/src/dialogs.rs | 34 +++++++++ crates/ui-egui/src/i18n/bg.tsv | 6 ++ crates/ui-egui/src/i18n/cs.tsv | 6 ++ crates/ui-egui/src/i18n/de.tsv | 6 ++ crates/ui-egui/src/i18n/es.tsv | 6 ++ crates/ui-egui/src/i18n/et.tsv | 6 ++ crates/ui-egui/src/i18n/fr.tsv | 6 ++ crates/ui-egui/src/i18n/hu.tsv | 6 ++ crates/ui-egui/src/i18n/it.tsv | 6 ++ crates/ui-egui/src/i18n/ja.tsv | 6 ++ crates/ui-egui/src/i18n/pl.tsv | 6 ++ crates/ui-egui/src/i18n/pt-br.tsv | 6 ++ crates/ui-egui/src/i18n/ru.tsv | 6 ++ crates/ui-egui/src/i18n/sr-latn.tsv | 6 ++ crates/ui-egui/src/i18n/sr.tsv | 6 ++ crates/ui-egui/src/i18n/tr.tsv | 6 ++ crates/ui-egui/src/i18n/uk.tsv | 6 ++ crates/ui-egui/src/i18n/vi.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 ++ crates/ui-egui/src/lib.rs | 111 ++++++++++++++++++++++++++-- docs/gaps.md | 5 +- docs/mcp.md | 4 +- docs/ui-parity.md | 5 +- 32 files changed, 424 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0f9166099..83881470d 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-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end, #971; 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-14 · **Change:** trivial (title rename and account settings; previously trivial: progress log: All Caps text in PDFs, #1473; previously trivial: progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end, #971; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. | | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | | 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion | @@ -327,6 +328,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | | 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | diff --git a/contributors/people.toml b/contributors/people.toml index 8bf8fc61c..c669b474d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -75,6 +75,9 @@ sync_github_name = false real_name = "Manuel Carmona" display_name = "manucarcor" [people.odedigo] + +[people.neupaneprashant] +real_name = "Prashant Neupane" [people.cyril265] display_name = "cyril265" diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bc83f5613..c4212a5cc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -31,8 +31,39 @@ pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult { Ok(r) } +/// Rename the current file in its directory, or name a document before its first save. +fn rename(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?.trim(); + if name.is_empty() + || name.chars().count() > 200 + || name.ends_with(['.', ' ']) + || name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')) + || matches!(name, "." | "..") + { + return Err(CmdError::Params("Enter a valid document name (up to 200 characters, without path separators).".into())); + } + if let Some(path) = &s.path { + let mut destination = path.with_file_name(name); + // The field edits the stem; retain the original format, including macro/template files. + if let Some(ext) = path.extension() { + destination.set_file_name(format!("{name}.{}", ext.to_string_lossy())); + } + if destination != *path { + #[cfg(not(target_arch = "wasm32"))] + { + crate::io::rename_file(path, &destination).map_err(CmdError::Failed)?; + } + s.path = Some(destination); + } + } else { + s.file_name = Some(name.to_string()); + } + Ok(json!({"name": name, "path": s.path})) +} + pub fn specs() -> Vec { vec![ + CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name without its extension)}"#).pure(), CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(), CommandSpec::new("file.open", "Open", "File", open) .key("Mod+O / Mod+F12") diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 3f4dcf2d5..595b36b9a 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -1114,6 +1114,7 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult { if show_in != "original" || p::str(v, "original").is_some() { // The result is a new, untitled document: saving it never overwrites either file. s.path = None; + s.file_name = None; } s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured})) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index c29499d4c..2c4df20f1 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -449,6 +449,40 @@ pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, Str std::fs::read(path).map_err(|e| format!("{path}: {e}")) } +/// Rename without replacing another file, respecting both automation access roots. +#[cfg(not(target_arch = "wasm32"))] +pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { + crate::file_access::check_read(&source.to_string_lossy())?; + crate::file_access::check_write(&source.to_string_lossy())?; + crate::file_access::check_write(&destination.to_string_lossy())?; + // Creating a sibling hard link refuses collisions atomically and keeps the original bytes. + if let Err(e) = std::fs::hard_link(source, destination) { + if e.kind() == std::io::ErrorKind::AlreadyExists && only_case_differs(source, destination) { + return std::fs::rename(source, destination).map_err(|e| format!("Could not rename document: {e}")); + } + return Err(format!("Could not rename document: {e}")); + } + if let Err(e) = std::fs::remove_file(source) { + let _ = std::fs::remove_file(destination); + return Err(format!("Could not rename document: {e}")); + } + Ok(()) +} + +/// Whether `destination` names `source` in the same directory spelled with different case, and +/// the directory holds no entry spelled exactly as `destination`. +#[cfg(not(target_arch = "wasm32"))] +fn only_case_differs(source: &std::path::Path, destination: &std::path::Path) -> bool { + let (Some(dir), Some(from), Some(to)) = (source.parent(), source.file_name(), destination.file_name()) else { + return false; + }; + if destination.parent() != Some(dir) || from == to || from.to_string_lossy().to_lowercase() != to.to_string_lossy().to_lowercase() { + return false; + } + let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; + std::fs::read_dir(dir).is_ok_and(|entries| entries.filter_map(Result::ok).all(|entry| entry.file_name() != to)) +} + /// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation /// read root when there is one: enough to tell a file's format by its start. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f15030e9e..6387eac8b 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -337,6 +337,9 @@ pub struct Session { /// Formatting picked with a collapsed caret, applied to the next typed text. pub pending: Option, pub path: Option, + /// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file + /// name, not document metadata, so it is not part of the document or its undo history. + pub file_name: Option, pub dirty: bool, /// Where the custom dictionary is kept on disk, if the host wired one up. Only the desktop /// app does: headless sessions (MCP, `wordcraft-cli run`), tests and the web build leave @@ -586,6 +589,7 @@ impl Session { view: ViewState::default(), pending: None, path: None, + file_name: None, user_dict_path: None, dirty: false, clipboard: None, @@ -985,6 +989,7 @@ impl Session { self.doc = doc; self.doc.ensure_nonempty(); self.password = None; + self.file_name = None; self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); self.pending = None; self.also_selected.clear(); @@ -1024,6 +1029,7 @@ impl Session { view: _, pending: _, path: _, + file_name: _, // The custom dictionary file, like the file path: host wiring, not an edit. user_dict_path: _, dirty, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7c0cbb77e..7b366888b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5541,6 +5541,68 @@ fn insert_picture_takes_a_windows_metafile() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn rename_document_preserves_saved_contents_and_rejects_collisions() { + let dir = std::env::temp_dir().join(format!("wordcraft-rename-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("original.docm"); + let renamed = dir.join("Notes.docm"); + let existing = dir.join("existing.docm"); + std::fs::write(&original, b"original bytes").unwrap(); + std::fs::write(&existing, b"keep these bytes").unwrap(); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.path = Some(original.clone()); + s.dirty = true; + assert!(s.run("file.rename", &json!({"name": "existing"})).is_err()); + assert_eq!(s.path.as_ref(), Some(&original)); + assert_eq!(std::fs::read(&existing).unwrap(), b"keep these bytes"); + let roots = crate::file_access::AutomationRoots::new(Some(&dir), None).unwrap(); + assert!(crate::file_access::confine(roots.as_ref(), || s.run("file.rename", &json!({"name": "Notes"}))).is_err()); + assert!(original.exists()); + assert!(!renamed.exists()); + s.run("file.rename", &json!({"name": " Notes "})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&renamed)); + assert!(s.dirty); + assert!(!original.exists()); + assert_eq!(std::fs::read(&renamed).unwrap(), b"original bytes"); + s.run("file.rename", &json!({"name": "Notes"})).unwrap(); + s.run("file.rename", &json!({"name": "notes"})).unwrap(); + let lower = dir.join("notes.docm"); + assert_eq!(s.path.as_ref(), Some(&lower)); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert!(names.contains(&"notes.docm".into()) && !names.contains(&"Notes.docm".into()), "{names:?}"); + assert_eq!(std::fs::read(&lower).unwrap(), b"original bytes"); + assert!(s.run("file.rename", &json!({"name": "existing"})).is_err()); + assert_eq!(s.path.as_ref(), Some(&lower)); + assert_eq!(std::fs::read(&existing).unwrap(), b"keep these bytes"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn rename_unsaved_document_names_the_file_not_the_document() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.doc.core.title = "Quarterly report".into(); + for name in ["", " ", "../report", "a/b", "a\\b", "a\nb", "name."] { + assert!(s.run("file.rename", &json!({"name": name})).is_err(), "{name:?}"); + assert!(s.file_name.is_none()); + } + s.run("file.rename", &json!({"name": " Résumé notes "})).unwrap(); + assert_eq!(s.file_name.as_deref(), Some("Résumé notes")); + assert_eq!(s.doc.core.title, "Quarterly report"); + assert!(s.path.is_none()); + assert!(!s.dirty); + assert!(!s.can_undo()); + s.run("file.new", &json!({})).unwrap(); + assert!(s.file_name.is_none()); + s.run("file.rename", &json!({"name": "Draft"})).unwrap(); + s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap(); + assert!(s.file_name.is_none()); + s.run("file.new", &json!({})).unwrap(); + s.run("file.rename", &json!({"name": "Draft"})).unwrap(); + s.run("review.compare", &json!({"text": "changed", "showIn": "new"})).unwrap(); + assert!(s.file_name.is_none() && s.path.is_none()); +} + /// #1344: File › Info › Inspect Document finds hidden text by its effective formatting (direct, /// character style, paragraph style) in every story — body, header, footer, footnote, comment, /// text box — and Remove All deletes exactly that text, in one undo step, through a DOCX save. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c89f53266..cf187a016 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -286,9 +286,12 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { // Account / community. let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click()); ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Account"))); let initials: String = app.session.user_initials().chars().take(2).collect(); ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent); - resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)); + if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() { + let _ = app.run("ui.backstage", json!({"value": true, "page": "options", "pane": "general"})); + } ui.add_space(8.0); if app.ui.show_discord { let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click()); @@ -360,14 +363,29 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { }; let font = TypeRung::Chrome.semibold(); let track = TypeRung::Chrome.tracking(); - let w = text_width(ui.painter(), &title, &font, track, t.text); let cx = full.center().x; - if cx - w / 2.0 > qat_end + 10.0 && cx + w / 2.0 < right_start - 10.0 { + let available = 2.0 * (cx - qat_end - 10.0).min(right_start - 10.0 - cx); + let mut shown: String = title.chars().take(240).collect(); + if text_width(ui.painter(), &shown, &font, track, t.text) > available { + while !shown.is_empty() && text_width(ui.painter(), &format!("{shown}…"), &font, track, t.text) > available { + shown.pop(); + } + shown.push('…'); + } + let w = text_width(ui.painter(), &shown, &font, track, t.text); + if available > 24.0 { + let rect = Rect::from_center_size(pos2(cx, full.center().y), vec2(w + 8.0, control_h)); + let response = ui.interact(rect, ui.id().with("document_name"), Sense::click()).on_hover_text(tl!("Rename Document")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &title)); + if response.clicked() { + let _ = app.run("file.rename", json!({})); + } + paint_text( ui.painter(), pos2(cx - w / 2.0, full.center().y - TypeRung::Chrome.line_height() / 2.0), Align2::LEFT_TOP, - &title, + &shown, &font, track, t.text, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dddf40d01..9f8e73299 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -140,6 +140,11 @@ fn special_menu(ui: &mut Ui, extra: &FindExtra) -> Option<&'static str> { #[derive(Clone, Debug, Serialize)] #[serde(tag = "dialog", rename_all = "camelCase")] pub enum Dialog { + RenameDocument { + name: String, + message: String, + focus: bool, + }, Font { /// The font (Latin text) and the Asian text font, as the font lists name them, and their /// values when the dialog opened: OK changes only the ones changed (#505). @@ -879,6 +884,7 @@ pub(crate) fn color_field(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut impl Dialog { pub fn name(&self) -> &'static str { match self { + Dialog::RenameDocument { .. } => "renameDocument", Dialog::Font { .. } => "font", Dialog::Paragraph { .. } => "paragraph", Dialog::Tabs { .. } => "tabs", @@ -1149,6 +1155,7 @@ impl Dialog { } } "pasteSpecial" => Dialog::paste_special(app, &json!({})), + "renameDocument" => Dialog::RenameDocument { name: app.title_stem(), message: String::new(), focus: true }, "about" => Dialog::About { tab: 0 }, "contributors" => Dialog::About { tab: 1 }, "models" => Dialog::About { tab: 2 }, @@ -1335,6 +1342,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { let mut open = true; let mut close = false; let title = match &d { + Dialog::RenameDocument { .. } => "Rename Document", Dialog::Font { .. } => "Font", Dialog::Paragraph { .. } => "Paragraph", Dialog::Tabs { .. } => "Tabs", @@ -1607,6 +1615,32 @@ fn apply_font(app: &mut WordApp, d: &Dialog) { /// Returns true to close. fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { match d { + Dialog::RenameDocument { name, message, focus } => { + ui.label(tl!("Document name")); + let r = ui.add(egui::TextEdit::singleline(name).desired_width(320.0).char_limit(200)); + if *focus { + r.request_focus(); + *focus = false; + } + if !message.is_empty() { + ui.colored_label(ui.visuals().error_fg_color, message.as_str()); + } + let (ok, cancel) = buttons(ui, tl!("Rename")); + if cancel { + return true; + } + if ok && name.trim() == app.title_stem() { + return true; + } + if ok { + match app.run("file.rename", json!({"name": name})) { + Ok(_) => return true, + Err(e) => *message = e, + } + } + false + } + Dialog::Font { font, font_ea, asian, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing, .. } => { // Build the sample text format for the preview pane below. let preview_size = size.trim().parse::().unwrap_or(12.0).clamp(6.0, 72.0); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f135f6a0d..2928a8060 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2462,6 +2462,12 @@ Table Properties… Свойства на таблица… Search Търсене Title Bar Заглавна лента + +# Document naming and account controls. + Rename Document Преименуване на документ + Document name Име на документа + Rename Преименуване + Account Акаунт Privacy Options Настройки за поверителност Trust Center Център за сигурност Privacy for this document Поверителност на този документ diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a9add49f6..5d6631000 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2087,6 +2087,12 @@ Automatic ({region}) Automaticky ({region}) Search Hledat Title Bar Záhlaví okna + +# Document naming and account controls. + Rename Document Přejmenovat dokument + Document name Název dokumentu + Rename Přejmenovat + Account Účet Privacy Options Možnosti ochrany osobních údajů Trust Center Centrum zabezpečení Privacy for this document Soukromí tohoto dokumentu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1f6ad51cb..961a5bf3a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2081,6 +2081,12 @@ Automatic ({region}) Automatisch ({region}) Search Suchen Title Bar Titelleiste + +# Document naming and account controls. + Rename Document Dokument umbenennen + Document name Dokumentname + Rename Umbenennen + Account Konto Privacy Options Datenschutzoptionen Trust Center Sicherheitscenter Privacy for this document Datenschutz für dieses Dokument diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 94409af75..ecb0b04fa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2448,6 +2448,12 @@ preview Title Título Start-up Inicio de la aplicación Show the Start screen when WordCraft starts Mostrar la pantalla de inicio al abrir WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Activado: WordCraft se abre en Archivo › Inicio, con un documento en blanco, las plantillas y tus documentos recientes. Desactivado: abre un documento en blanco. + +# Document naming and account controls. + Rename Document Cambiar nombre del documento + Document name Nombre del documento + Rename Cambiar nombre + Account Cuenta Privacy Options Opciones de privacidad Trust Center Centro de confianza Privacy for this document Privacidad de este documento diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index a620b2742..ec479f1e5 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2116,6 +2116,12 @@ Start-up Käivitamine Show the Start screen when WordCraft starts Näita WordCrafti käivitamisel avakuva On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Sees: WordCraft avaneb vaates Fail › Avaleht, kus on tühi dokument, mallid ja hiljutised dokumendid. Väljas: avaneb tühi dokument. + +# Document naming and account controls. + Rename Document Nimeta dokument ümber + Document name Dokumendi nimi + Rename Nimeta ümber + Account Konto Privacy Options Privaatsussuvandid Trust Center Usalduskeskus Privacy for this document Selle dokumendi privaatsus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index aa5feaeaa..f80080aad 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2090,6 +2090,12 @@ Table Properties… Propriétés du tableau… Search Rechercher Title Bar Barre de titre + +# Document naming and account controls. + Rename Document Renommer le document + Document name Nom du document + Rename Renommer + Account Compte Privacy Options Options de confidentialité Trust Center Centre de gestion de la confidentialité Privacy for this document Confidentialité de ce document diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 5d78a83b2..03ac09ce4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2470,6 +2470,12 @@ Insert Caption… Felirat beszúrása… Table Properties… Táblázat tulajdonságai… Search Keresés + +# Document naming and account controls. + Rename Document Dokumentum átnevezése + Document name Dokumentum neve + Rename Átnevezés + Account Fiók Privacy Options Adatvédelmi beállítások Trust Center Adatvédelmi központ Privacy for this document A dokumentum adatvédelme diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2189b5720..f66b05af2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2457,6 +2457,12 @@ preview Title Esempio Automatic ({region}) Automatico ({region}) Search Cerca Title Bar Barra del titolo + +# Document naming and account controls. + Rename Document Rinomina documento + Document name Nome del documento + Rename Rinomina + Account Account Privacy Options Opzioni di privacy Trust Center Centro protezione Privacy for this document Privacy di questo documento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 988d2f8d4..53c559721 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2082,6 +2082,12 @@ Start-up 起動 Show the Start screen when WordCraft starts WordCraft の起動時にスタート画面を表示する On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. オン: WordCraft は [ファイル] › [ホーム] で開き、白紙の文書、テンプレート、最近使った文書を表示します。オフ: 白紙の文書を開きます。 + +# Document naming and account controls. + Rename Document ドキュメント名の変更 + Document name ドキュメント名 + Rename 名前の変更 + Account アカウント Privacy Options プライバシー オプション Trust Center セキュリティ センター Privacy for this document このドキュメントのプライバシー diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 548db835e..915299dfa 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2466,6 +2466,12 @@ preview Title Tytuł Typing Pisanie Search Wyszukaj Title Bar Pasek tytułu + +# Document naming and account controls. + Rename Document Zmień nazwę dokumentu + Document name Nazwa dokumentu + Rename Zmień nazwę + Account Konto Privacy Options Opcje prywatności Trust Center Centrum zaufania Privacy for this document Prywatność tego dokumentu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a9ff8489e..c85fe6017 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2094,6 +2094,12 @@ Start-up Inicialização Show the Start screen when WordCraft starts Mostrar a tela inicial ao abrir o WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Ativado: o WordCraft abre em Arquivo › Início, com um documento em branco, os modelos e seus documentos recentes. Desativado: abre um documento em branco. + +# Document naming and account controls. + Rename Document Renomear documento + Document name Nome do documento + Rename Renomear + Account Conta Privacy Options Opções de privacidade Trust Center Central de confiabilidade Privacy for this document Privacidade deste documento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e2a3a32f4..d17aba7ab 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2472,6 +2472,12 @@ preview Title Пример Level 7 Уровень 7 Level 8 Уровень 8 Level 9 Уровень 9 + +# Document naming and account controls. + Rename Document Переименовать документ + Document name Имя документа + Rename Переименовать + Account Учётная запись Privacy Options Параметры конфиденциальности Trust Center Центр управления безопасностью Privacy for this document Конфиденциальность этого документа diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 82ebf59af..75ed6ca10 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2092,6 +2092,12 @@ Start-up Pokretanje Show the Start screen when WordCraft starts Prikaži početni ekran pri pokretanju programa WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Uključeno: WordCraft se otvara na Fajl › Početak, sa praznim dokumentom, šablonima i nedavnim dokumentima. Isključeno: otvara prazan dokument. + +# Document naming and account controls. + Rename Document Preimenuj dokument + Document name Ime dokumenta + Rename Preimenuj + Account Nalog Privacy Options Opcije privatnosti Trust Center Centar za bezbednost Privacy for this document Privatnost ovog dokumenta diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 3f3d3ab2a..c75032ad3 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2096,6 +2096,12 @@ Start-up Покретање Show the Start screen when WordCraft starts Прикажи почетни екран при покретању програма WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Укључено: WordCraft се отвара на Фајл › Почетак, са празним документом, шаблонима и недавним документима. Искључено: отвара празан документ. + +# Document naming and account controls. + Rename Document Преименуј документ + Document name Име документа + Rename Преименуј + Account Налог Privacy Options Опције приватности Trust Center Центар за безбедност Privacy for this document Приватност овог документа diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 46d687066..c42ec2a55 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2471,6 +2471,12 @@ preview Title Örnek Level 7 Düzey 7 Level 8 Düzey 8 Level 9 Düzey 9 + +# Document naming and account controls. + Rename Document Belgeyi yeniden adlandır + Document name Belge adı + Rename Yeniden adlandır + Account Hesap Privacy Options Gizlilik Seçenekleri Trust Center Güven Merkezi Privacy for this document Bu belgenin gizliliği diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ee532c3a7..084548283 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2094,6 +2094,12 @@ Start-up Запуск Show the Start screen when WordCraft starts Показувати початковий екран під час запуску WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Увімкнено: WordCraft відкривається на сторінці Файл › Основне з порожнім документом, шаблонами та нещодавніми документами. Вимкнено: відкривається порожній документ. + +# Document naming and account controls. + Rename Document Перейменувати документ + Document name Ім’я документа + Rename Перейменувати + Account Обліковий запис Privacy Options Параметри конфіденційності Trust Center Центр безпеки Privacy for this document Конфіденційність цього документа diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e51c4c276..2151cb7fe 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2539,3 +2539,9 @@ Heading text Văn bản đề mục Insert reference to: Chèn tham chiếu tới: Reference type: Loại tham chiếu: + +# Rename Document (#1016) + Account Tài khoản + Document name Tên tài liệu + Rename Đổi tên + Rename Document Đổi tên tài liệu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a9a5443f2..3ba6f3e3e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2094,6 +2094,12 @@ Start-up 启动 Show the Start screen when WordCraft starts 启动 WordCraft 时显示开始屏幕 On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 开:WordCraft 打开时显示“文件 › 开始”,其中有空白文档、模板和最近使用的文档。关:直接打开空白文档。 + +# Document naming and account controls. + Rename Document 重命名文档 + Document name 文档名称 + Rename 重命名 + Account 账户 Privacy Options 隐私选项 Trust Center 信任中心 Privacy for this document 此文档的隐私 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bfc522d85..893048caf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2083,6 +2083,12 @@ Start-up 啟動 Show the Start screen when WordCraft starts 啟動 WordCraft 時顯示開始畫面 On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 開:WordCraft 開啟時顯示「檔案 › 常用」,其中有空白文件、範本和最近使用的文件。關:直接開啟空白文件。 + +# Document naming and account controls. + Rename Document 重新命名文件 + Document name 文件名稱 + Rename 重新命名 + Account 帳戶 Privacy Options 隱私權選項 Trust Center 信任中心 Privacy for this document 此文件的隱私 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a8bc0d55a..21961482b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -838,6 +838,7 @@ impl WordApp { return Ok(json!({"downloaded": name, "bytes": bytes.len()})); } let document = self.session.document_id(); + let old_path = self.session.path.clone(); let r = self.session.run(id, ¶ms).map_err(|e| e.to_string()); // When the document has been replaced (not when Open only showed its picker), a pending // "Save changes?" and AutoSave's go-ahead were both about the old one. @@ -856,6 +857,17 @@ impl WordApp { { self.autosave_path = self.session.path.clone(); } + // A renamed file is the same file: AutoSave keeps covering it, and Recent drops the old name. + if r.is_ok() + && id == "file.rename" + && let Some(old) = old_path.filter(|old| self.session.path.as_ref() != Some(old)) + { + if self.autosave_path.as_ref() == Some(&old) { + self.autosave_path = self.session.path.clone(); + } + let old = old.to_string_lossy(); + self.ui.recent.retain(|r| *r != old); + } if let Ok(v) = &r { self.keep_web_font(id, ¶ms, v); } @@ -1557,13 +1569,11 @@ impl WordApp { pub fn title_stem(&self) -> String { match &self.session.path { Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Document1").into()), - None => { - if self.session.doc.core.title.is_empty() { - tl!("Document1").into() - } else { - self.session.doc.core.title.clone() - } - } + None => match &self.session.file_name { + Some(name) => name.clone(), + None if self.session.doc.core.title.is_empty() => tl!("Document1").into(), + None => self.session.doc.core.title.clone(), + }, } } @@ -2075,6 +2085,7 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { let has = |k: &str| params.get(k).is_some_and(|v| !v.is_null()); let rule = params.get("rule").and_then(Value::as_str).map(str::to_ascii_uppercase); match id { + "file.rename" if !has("name") => Some("renameDocument"), "mailings.rules" if !has("field") => match rule.as_deref() { None | Some("IF") => Some("ruleIf"), Some("SKIPIF") => Some("ruleSkipIf"), @@ -2306,6 +2317,63 @@ mod tests { WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) } + #[test] + fn title_and_account_buttons_work() { + use egui_kittest::kittest::Queryable; + 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); + }, + app(), + ); + h.run_steps(6); + h.get_by_label("Document1 — Not saved").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("renameDocument")); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Rename").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert!(h.state().session.file_name.is_none()); + assert!(!h.state().session.dirty); + h.get_by_label("Document1 — Not saved").click(); + h.run_steps(2); + h.event(egui::Event::Key { + key: egui::Key::A, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { command: true, ..Default::default() }, + }); + h.event(egui::Event::Text("Project notes".into())); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Rename").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(h.state().title_stem(), "Project notes"); + assert_eq!(h.state().default_save_name(), "Project notes.docx"); + assert!(h.state().session.doc.core.title.is_empty()); + assert!(!h.state().session.dirty); + // A long name remains clickable, and Escape leaves it unchanged. + let long_name = "Notes ".repeat(25); + h.state_mut().run("file.rename", json!({"name": long_name})).unwrap(); + h.run_steps(2); + h.get_by_label(&format!("{} — Not saved", long_name.trim())).click(); + h.run_steps(2); + assert!(h.state().dialog.is_some()); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + assert_eq!(h.state().title_stem(), long_name.trim()); + h.get_by_label("Account").click(); + h.run_steps(2); + assert!(h.state().ui.backstage); + assert_eq!(h.state().ui.backstage_page, "options"); + assert_eq!(h.state().ui.options_pane, "general"); + assert!(h.query_by_label("User name:").is_some()); + } + /// File › Options › Advanced › Editing: alignment guides are a UI command, on by default and /// kept between runs. #[test] @@ -4161,6 +4229,35 @@ mod tests { } } + #[test] + fn renaming_a_saved_file_keeps_autosave_and_replaces_its_recent_entry() { + let dir = scratch("rename-autosave"); + let path = dir.join("notes.docx"); + let renamed = dir.join("Notes v2.docx"); + let mut a = typed(); + a.run("file.save", json!({"path": path.to_string_lossy()})).unwrap(); + assert!(a.autosaves()); + a.run("file.rename", json!({"name": "Notes v2"})).unwrap(); + assert_eq!(a.session.path.as_deref(), Some(renamed.as_path())); + assert!(a.autosaves(), "AutoSave still covers the renamed file"); + assert_eq!(a.ui.recent.first(), Some(&renamed.to_string_lossy().to_string())); + assert!(!a.ui.recent.contains(&path.to_string_lossy().to_string()), "{:?}", a.ui.recent); + a.run("text.insert", json!({"text": "More "})).unwrap(); + a.autosave_tick(now_ms() + 10_000.0); + assert!(!a.session.dirty, "AutoSave wrote the renamed file"); + let saved = wordcraft_engine::io::open_path(&renamed).unwrap(); + assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("More ")); + assert!(!path.exists()); + + // A file opened but not saved in this session doesn't gain AutoSave by being renamed. + let mut b = app(); + b.run("file.open", json!({"path": renamed.to_string_lossy()})).unwrap(); + assert!(!b.saved_here()); + b.run("file.rename", json!({"name": "Notes v3"})).unwrap(); + assert!(!b.saved_here() && !b.autosaves()); + let _ = std::fs::remove_dir_all(&dir); + } + /// Web: the download callback couldn't report a failure, so Save marked the document saved /// and the prompt went ahead even when no download started. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index ba11b7a91..4ba224f6b 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-14 · **Change:** trivial (ODT tracked insertions and deletions, gap 19; previously trivial: RTF tracked insertions and deletions, gap 19; 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-14 · **Change:** trivial (title rename and account settings; previously trivial: ODT tracked insertions and deletions, gap 19; previously trivial: RTF tracked insertions and deletions, gap 19; 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. @@ -11,6 +11,8 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees). +Document renaming: desktop renaming requires a filesystem that supports hard links (exFAT/FAT drives and some network shares refuse it). Behaviour: [UI parity](ui-parity.md). + ## Ranked | # | Gap | B | Evidence | Impact | Hours | Doc | @@ -80,6 +82,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | | 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067) implemented; numerical limits not compared in Word | | 2026-10-12 | minor | Italian proofing landed (it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar); gap #8 narrowed to the remaining languages | diff --git a/docs/mcp.md b/docs/mcp.md index ed539f138..65d500d6c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -45,8 +45,8 @@ config works across the suite) keep the agent to the folders you give it: `styles.organizer` and `styles.copyFrom` read. The pictures an opened HTML file names are read from beside that file, so they are inside the read root too (a link that leads out is not followed). - Every file a command writes must lie inside the **write root**: `save_document`, `file.save`, - `file.saveAs`, `file.exportPdf`, `file.exportPng`, `file.printPdf`, `file.saveTemplate`, and `mailings.finish` with - a `path`. + `file.saveAs`, `file.rename` (the file and its new name), `file.exportPdf`, `file.exportPng`, `file.printPdf`, + `file.saveTemplate`, and `mailings.finish` with a `path`. - Read and write access are separate. Give the same folder twice for both; a root left out grants none of its access (with only `--automation-read-root`, nothing can be written to disk). Without either flag nothing changes. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 171d962d1..29639f689 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,11 +1,13 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Cross-reference dialog, #916; previously trivial: web Save Changes prompt waits for the save picker, #1315; previously trivial: AutoFit in a nested table, whole cells shaded for a selection through a table, #971; 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-14 · **Change:** trivial (Cross-reference dialog, #916; previously trivial: title rename and account settings; previously trivial: web Save Changes prompt waits for the save picker, #1315; previously trivial: AutoFit in a nested table, whole cells shaded for a selection through a table, #971; 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 own (`AGENTS.md`: no Microsoft iconography, ever). +Document title opens Rename Document; the account badge opens Options › General for user name and initials. Saved documents retain their format and refuse conflicting names; desktop renaming requires a filesystem that supports hard links. Web renaming changes the next download name. An unsaved document's name is only the name Save suggests: its Title property is left alone. + **Dimension: ~65% (estimated), 59–92 h to full** (the sum of the rows below; dialog depth is counted here, not under features). | Area | WordCraft | Word | Parity | Hours | @@ -50,6 +52,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| | 2026-10-12 | trivial | Cross-reference dialog: reference type (heading, bookmark, equation, figure, table), what to insert and the item; the ribbon button opened nothing before (#916); modal dialog count 55 | +| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | From e9d5da31d46b4af86fc9e2837a78b9e66318e731 Mon Sep 17 00:00:00 2001 From: przemek-arch Date: Wed, 14 Oct 2026 21:31:04 +0200 Subject: [PATCH 105/199] Track Changes: rejecting a whole inserted paragraph keeps the next paragraph's style and numbering (#1020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject All (and Accept All of a whole deleted paragraph) joined the next paragraph into the emptied one, so the next paragraph took the removed paragraph's properties: a § heading after a rejected list item became a list item and later § numbers shifted. An emptied paragraph is now removed whole, as Word does. Found while round-tripping a Word-saved contract. Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 12 ++++++++++++ crates/engine/src/cmd/review.rs | 9 ++++++--- crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 41671759e..54097023e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -200,6 +200,18 @@ pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Pa Ok(true) } +/// Remove the paragraph at `path` when it is empty, carries no section break and another +/// paragraph follows in the same container: the next paragraph keeps its own properties. Returns +/// `false`, changing nothing, otherwise. +pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path) -> Result { + let empty = s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_none()); + if !empty || !has_next_para(s, story, path) { + return Ok(false); + } + s.doc.remove_block(story, path)?; + Ok(true) +} + /// Tracked deletion: own insertions are removed, other text is marked deleted. pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result { let rid = new_revision(s, RevisionKind::Delete); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 87d6c49b3..2ba4a79d6 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -323,9 +323,12 @@ fn resolve_para( return Ok(()); } // An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away: - // the next paragraph joins this one (the split is undone). The mark at the end of a story - // or cell can't go, so it just stops being a revision. - if ((del.is_some() && accept) || (ins.is_some() && !accept)) && super::join_next_para(s, story, path)? { + // the next paragraph joins this one (the split is undone). A paragraph left empty goes away + // whole, so the next one keeps its own style and numbering, as in Word. The mark at the end + // of a story or cell can't go, so it just stops being a revision. + if ((del.is_some() && accept) || (ins.is_some() && !accept)) + && (super::remove_empty_para(s, story, path)? || super::join_next_para(s, story, path)?) + { return Ok(()); } let para = s.doc.para_mut(story, path)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7b366888b..bc4619e1f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1830,6 +1830,35 @@ fn reject_one_paragraph_break_and_rejecting_a_typed_paragraph() { assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.style.as_deref(), Some("Heading1")); } +#[test] +fn reject_all_of_an_inserted_paragraph_keeps_the_next_paragraphs_style() { + // A whole paragraph inserted before a heading, in another style: rejecting it removes the + // paragraph, and the heading keeps its own style (it used to take the rejected one's). + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Intro"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Heading"})); + run(&mut s, "para.style", json!({"style": "Heading1"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + run(&mut s, "text.insert", json!({"text": "Inserted"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "review.trackChanges", json!({"value": false})); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + run(&mut s, "para.style", json!({"style": "Normal"})); + assert_eq!(body_texts(&s), ["Intro", "Inserted", "Heading"]); + let style = |s: &Session, i: usize| s.doc.para_at(&Pos::body(i, 0)).unwrap().props.style.clone(); + assert_eq!(style(&s, 2).as_deref(), Some("Heading1")); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(body_texts(&s), ["Intro", "Heading"]); + assert_eq!(style(&s, 1).as_deref(), Some("Heading1")); + // Accepting instead keeps all three, each with its style. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(body_texts(&s), ["Intro", "Inserted", "Heading"]); + assert_eq!(style(&s, 2).as_deref(), Some("Heading1")); +} + #[test] fn tracked_backspace_and_delete_mark_a_paragraph_break_deleted() { let two = || { From 8196624e830470a400263b08d469d4b0605e7266 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:31:22 +0200 Subject: [PATCH 106/199] Caret: Down and Up from a line start beside a table go into it, not past it (#1021) Down from the start of the line above a table (or Up from the start of the paragraph after it) skipped the whole table: `DocLayout::vertical` took the nearest line covering the caret's x, and a cell's lines start a cell margin in from the table's edge, so the paragraph beyond the table won. Lines between the caret's paragraph and that line in document order now come first: the caret goes into the nearest such line's table row, to the cell under it or the horizontally nearest. Another cell of the caret's own row never counts, so Down in a table still goes to the cell below. Pinned in `tests_typing.rs` and `docs/typing-parity.md`. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/engine/src/tests_typing.rs | 28 +++++++++++++++ crates/layout/src/hit.rs | 58 ++++++++++++++++++++++++------- docs/typing-parity.md | 4 ++- 4 files changed, 80 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 83881470d..a5cacfd76 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-14 · **Change:** trivial (title rename and account settings; previously trivial: progress log: All Caps text in PDFs, #1473; previously trivial: progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end, #971; 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-14 · **Change:** trivial (title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; 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. @@ -192,6 +192,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Table fixes (#971): a double-click on a nested table's column border fits the column within the cell around the table (it could run out to the page's right margin); a selection from text into or across a table shades the whole cells of the rows it takes, not just their letters; Shift+Right at the end of a cell's text selects that cell first, as in Word, and the next press takes the next cell too | | 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged | | 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon | +| 2026-10-12 | Down and Up from the start of a line beside a table go into it (#1007): the caret, left of the first cell's text there, used to skip the whole table to the paragraph beyond it; it now lands in the cell under it or the nearest one, as in Word | | 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` | | 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show | | 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | @@ -369,6 +370,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: Ink to Shape recognises single-stroke arrows | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) | +| 2026-10-12 | trivial | Progress log: Down and Up no longer skip a table (#1007) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 1fb043840..b5c5a7129 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -719,6 +719,34 @@ fn alt_shift_left_and_right_promote_and_demote_headings_and_list_items() { assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(0))]); } +#[test] +fn down_and_up_from_a_line_start_beside_a_table_go_into_it() { + let mut s = typed("Intro line\n"); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let (table, _, _) = s.sel.focus.path.cell().unwrap(); + typ(&mut s, "a"); + let cell = |r: u32, c: u32| { + let mut p = table.0.clone(); + p.extend([r, c, 0]); + wordcraft_doc::Path(p) + }; + // From the start of the line above, left of the cells' text: the first cell, then the one + // below, not the paragraph after the table. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({})); + assert_eq!(s.sel.focus.path, cell(0, 0)); + run(&mut s, "caret.down", json!({})); + assert_eq!(s.sel.focus.path, cell(1, 0), "then the cell below"); + // Up from the start of the paragraph after it: the last row's first cell. + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "caret.up", json!({})); + assert_eq!(s.sel.focus.path, cell(1, 0)); + // Shift+Down from the line above takes the first row, not the whole table. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({"extend": true})); + assert_eq!(s.sel.focus.path, cell(0, 1)); +} + #[test] fn alt_x_toggles_between_a_character_and_its_code() { if !cfg!(target_os = "macos") { diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 03246746f..e58b782f4 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -176,6 +176,37 @@ pub fn page_lines(page: &Page, story: StoryRef) -> Vec> { v } +/// Of `lines`, the one nearest the caret moving up (`dir < 0`) or down: the lowest above it or +/// the highest below it. +fn nearest<'a, 'b>(lines: &[&'b LineHit<'a>], dir: i32) -> Option<&'b LineHit<'a>> { + if dir < 0 { lines.iter().copied().max_by(|a, b| a.top.total_cmp(&b.top)) } else { lines.iter().copied().min_by(|a, b| a.top.total_cmp(&b.top)) } +} + +/// The line of `lines` to go to: of the nearest one and the lines of the other cells in its table +/// row, the one across page x `gx` (else the one horizontally nearest it), on a tie the nearer. +fn in_row<'a, 'b>(lines: &[&'b LineHit<'a>], gx: f32, dir: i32) -> Option<&'b LineHit<'a>> { + let n = nearest(lines, dir)?; + let off = |l: &LineHit| (l.left - gx).max(gx - l.right).max(0.0); + lines + .iter() + .copied() + .filter(|l| (l.path == n.path && l.li == n.li) || beside(n.path, l.path)) + .min_by(|a, b| off(a).total_cmp(&off(b)).then(if dir < 0 { b.top.total_cmp(&a.top) } else { a.top.total_cmp(&b.top) })) +} + +/// Whether paragraph `l` is in another cell of the table row paragraph `p` is in: where their +/// paths part is a cell index (`[block, row, cell, block, …]`). +fn beside(p: &Path, l: &Path) -> bool { + p.0.iter().zip(&l.0).position(|(x, y)| x != y).is_some_and(|i| i % 3 == 2) +} + +/// Whether paragraph `l` comes between paragraphs `a` and `b` (either way round) in document +/// order, not in another cell of a table row either of them is in (that stands beside it). +fn between(a: &Path, b: &Path, l: &Path) -> bool { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + lo < l && l < hi && !beside(a, l) && !beside(b, l) +} + /// The position in turned text (a table cell's text direction) of `story` under page point /// (x, y), if there is turned text there. fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option { @@ -446,18 +477,21 @@ impl DocLayout { } }) .collect(); - let target = if dir < 0 { - cands - .iter() - .filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0) - .max_by(|a, b| a.top.total_cmp(&b.top)) - .or_else(|| cands.iter().max_by(|a, b| a.top.total_cmp(&b.top))) - } else { - cands - .iter() - .filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0) - .min_by(|a, b| a.top.total_cmp(&b.top)) - .or_else(|| cands.iter().min_by(|a, b| a.top.total_cmp(&b.top))) + let across: Vec<&LineHit> = cands.iter().copied().filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0).collect(); + let target = match nearest(&across, dir) { + // The nearest line across `gx`, unless lines between the caret's paragraph and that + // line's in document order come before it on the way: a table row whose cells' text + // `gx` misses (left of the table, or in the gap between two cells) is the next line, + // as in Word. + Some(t) => { + let skipped: Vec<&LineHit> = cands + .iter() + .copied() + .filter(|l| (if dir < 0 { l.top > t.top } else { l.top < t.top }) && between(&pos.path, t.path, l.path)) + .collect(); + if skipped.is_empty() { Some(t) } else { in_row(&skipped, gx, dir) } + } + None => in_row(&cands, gx, dir), }; if let Some(l) = target { let off = l.para.off_at_x(l.li, gx - l.x); diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 89c58fdff..558f3598a 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-14 · **Change:** trivial (Alt+numpad character codes, #961; previously trivial: Shift+Right at the end of a cell's text selects the cell first, #971; 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-14 · **Change:** 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 @@ -68,6 +68,7 @@ Windows/Linux keys are given first, macOS second. | Keys | Word's result | Test | |---|---|---| +| Down / Up (↓ / ↑) from a line beside a table | into the table's nearest row: the cell under the caret, or the nearest one when the caret is left or right of the cells' text (the start of the line above a table is left of the first cell's text); never past the table to the paragraph beyond it | `down_and_up_from_a_line_start_beside_a_table_go_into_it` | | Alt+Shift+Up / Down (⌃⇧↑ / ⌃⇧↓) | the paragraphs the selection touches swap with the paragraph above / below, keeping style and list membership; the selection moves with them; one undo step. A selection ending at the start of a paragraph doesn't take it along. Refused at the top or bottom of the story or cell, next to a table and across a section break | `alt_shift_down_then_up_moves_a_paragraph_and_back_and_undoes` | | Alt+Shift+Left / Right (⌃⇧← / ⌃⇧→) | Promote / Demote: a heading goes up or down one level (Heading 1 and Heading 9 are the ends); promoted body text becomes a heading at the level of the heading before it; a list item changes level like Shift+Tab / Tab | `alt_shift_left_and_right_promote_and_demote_headings_and_list_items` | | Outline view: Alt+Shift+Up / Down, Left / Right | as above, but a collapsed heading takes what is hidden under it along: moving passes whole shown paragraphs with their hidden content; promote/demote shifts the hidden subheadings too | `outline_collapse_hides_descendants_and_expand_restores` | @@ -192,6 +193,7 @@ Word. | Date | Change | Summary | |---|---|---| +| 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) | From 4fcf7da693f434a494b28d8596c3a7ac3839df82 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:31:35 +0200 Subject: [PATCH 107/199] macOS: Control-click opens the context menu, with its spelling suggestions (#1025) --- crates/ui-egui/src/canvas.rs | 48 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 28 ++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 354a06a5b..013b585d8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3308,6 +3308,54 @@ mod tests { assert_eq!(o.frame().unwrap().2.wrap, wordcraft_doc::para::Wrap::Square); } + /// #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")] + #[test] + fn control_click_on_macos_opens_the_context_menu() { + use egui_kittest::kittest::Queryable; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "The quick brwn fox"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 12)); + 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 (p, ht) = caret_screen(h.state_mut()).unwrap(); + let at = p + egui::vec2(0.0, ht / 2.0); + h.state_mut().session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0)); + h.step(); + let ctrl = egui::Modifiers { ctrl: true, ..Default::default() }; + // The events winit delivers for a Control-click, through the app's input hook as eframe runs it. + let send = |h: &mut egui_kittest::Harness<'_, WordApp>, events: Vec| { + let mut raw = egui::RawInput { events, ..Default::default() }; + h.state_mut().raw_input_hook(&mut raw); + for e in raw.events { + h.event(e); + } + h.step(); + }; + send(&mut h, vec![egui::Event::ModifiersChanged(ctrl), egui::Event::PointerMoved(at)]); + for pressed in [true, false] { + send(&mut h, vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: ctrl }]); + } + h.event(egui::Event::ModifiersChanged(Default::default())); + h.step(); + assert!(h.state().canvas.context_menu_open, "the Control-click opened the menu"); + h.get_by_label("brown").click(); + for _ in 0..3 { + h.step(); + } + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox"); + } + #[test] fn only_web_and_email_links_are_followed() { for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21961482b..af91b3f4e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -442,6 +442,8 @@ pub struct WordApp { queued_shots: Vec<(u64, f64, u32)>, pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, pub(crate) synthetic: Vec, + /// macOS: the primary button went down with Control, so it is being read as the secondary. + control_click: bool, /// Badge rects recorded during layout for the current keytip phase (cleared by `logic`, painted by `show`). pub(crate) keytip_rects: Vec<(egui::Rect, String)>, /// The Navigation pane's search box takes the keyboard on its next frame (Ctrl+F, #712). @@ -571,6 +573,7 @@ impl WordApp { queued_shots: Vec::new(), pending_shots: Vec::new(), synthetic: Vec::new(), + control_click: false, keytip_rects: Vec::new(), nav_search_focus: false, styled: false, @@ -1776,8 +1779,12 @@ impl WordApp { } } - /// Inject synthetic input events from the control channel (one pointer event per frame). + /// Read a Control-click as the right-click on macOS, then inject synthetic input events from + /// the control channel (one pointer event per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + if cfg!(target_os = "macos") { + control_click_as_secondary(&mut raw.events, &mut self.control_click); + } if self.synthetic.is_empty() { return; } @@ -2227,6 +2234,25 @@ pub fn now_ms() -> f64 { } } +/// macOS reads a Control-click as the right-click (winit reports the primary button with Control +/// held), so menus open on it as on a two-finger click. The release follows its press even if +/// Control was let go in between. +fn control_click_as_secondary(events: &mut [egui::Event], held: &mut bool) { + for e in events { + if let egui::Event::PointerButton { button: button @ egui::PointerButton::Primary, pressed, modifiers, .. } = e { + if *pressed { + *held = modifiers.ctrl && !modifiers.mac_cmd; + } + if *held { + *button = egui::PointerButton::Secondary; + } + if !*pressed { + *held = false; + } + } + } +} + #[cfg(test)] mod tests_perf; From 482401f3c47825b0aeb6e1250f8a4a84e4654e0a Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 21:31:53 +0200 Subject: [PATCH 108/199] DOCX: a hyperlink with an empty target is no link (#1005) (#1030) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/docx/src/read/story.rs | 2 +- crates/docx/tests/fixtures.rs | 39 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 2 +- 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a5cacfd76..180b0976f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -184,6 +184,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | On the web, pasting a picture while a comment, the Find box, a dialog field or the Backstage has the keyboard no longer inserts it into the document; it inserts once only when the page is the edit target (#1349) | | 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing | | 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. | +| 2026-10-12 | A DOCX hyperlink whose relationship has an empty target (as LibreOffice writes) is no link (#1005): no "Ctrl+Click to follow link" tooltip over the text, which keeps its Hyperlink style | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-12 | `--help` and `-h` print the usage and exit (#968): `wordcraft --help` lists the app's options without opening a window (it used to try to open a file named `--help`), and `wordcraft-cli --help` works for every command instead of failing with `unknown option --help` | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 0df872a7f..d30270b2d 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -439,7 +439,7 @@ impl Reader<'_> { "w:r" => self.read_run(sc, pb, k, rels, ctx, depth), "w:hyperlink" => { let mut c = ctx.clone(); - let url = k.attr("r:id").and_then(|id| rels.by_id(id)).map(|r| r.target.clone()); + let url = k.attr("r:id").and_then(|id| rels.by_id(id)).map(|r| r.target.trim().to_string()).filter(|u| !u.is_empty()); let anchor = k.attr("w:anchor").filter(|a| !a.is_empty()); c.link = match (url, anchor) { (Some(u), Some(a)) => Some(format!("{u}#{a}")), diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 9f6587be6..6b46052b7 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2133,3 +2133,42 @@ fn text_frames_read_and_round_trip() { assert_eq!(q[1].props.frame, p[1].props.frame); assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); } + +#[test] +fn hyperlink_with_empty_target_is_no_link() { + // LibreOffice writes `Target=""` for the Hyperlink character style without an address (#1005). + let body = r#"plainmailjump"#; + let bytes = docx(body, &[("rId2", "hyperlink", "https://x"), ("rId3", "hyperlink", "mailto:a@example.com")], &[]); + // Blank the first target and mark the mailto one external (the helper only does that for http). + let bytes = { + let mut zr = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut entries = Vec::new(); + for i in 0..zr.len() { + let mut f = zr.by_index(i).unwrap(); + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut buf).unwrap(); + if f.name() == "word/_rels/document.xml.rels" { + buf = String::from_utf8(buf) + .unwrap() + .replace("Target=\"https://x\"", "Target=\"\"") + .replace("a@example.com\"", "a@example.com\" TargetMode=\"External\"") + .into_bytes(); + } + entries.push((f.name().to_string(), buf)); + } + let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect(); + zip(&refs) + }; + let doc = wordcraft_docx::read(&bytes).unwrap(); + let p = doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p.plain_text(), "plainmailjump"); + assert_eq!(p.props_of_char(0).link, None); + assert_eq!(p.props_of_char(0).style.as_deref(), Some("Hyperlink")); + assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com")); + assert_eq!(p.props_of_char(9).link.as_deref(), Some("#top")); + // Round trip keeps exactly the real links. + let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + let p = back.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p.props_of_char(0).link, None); + assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com")); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 013b585d8..fe8dab748 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2729,7 +2729,7 @@ fn link_under(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, po let at = wordcraft_geom::Point::new(x, y); // The caret falls between characters: the one under the point is on either side of it. [(pos.off, para.next_boundary(pos.off)), (para.prev_boundary(pos.off), pos.off)].into_iter().filter(|(a, b)| a < b).find_map(|(a, b)| { - let link = para.props_of_char(a).link.clone()?; + let link = para.props_of_char(a).link.clone().filter(|l| !l.is_empty())?; let (from, to) = (Pos { off: a, ..pos.clone() }, Pos { off: b, ..pos.clone() }); layout.selection_rects(doc, &from, &to, page).into_iter().any(|(pi, r)| pi == page && r.contains(at)).then_some(link) }) From 6ed1877ce6e182d47b3440990db47cb305753405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Lo=C5=A1=C3=A1k?= Date: Wed, 14 Oct 2026 21:47:35 +0200 Subject: [PATCH 109/199] i18n(cs): complete the Czech catalog (built-in styles, equation gallery) (#978) * i18n(cs): wording fixes after #387 SmartArt graphic and layout terms consistent with the rest of the catalog, inline equation, plural-safe print count, grammar terminology (mluvnice), tab stop type, file-not-found gender, review author label. Also adds the [people.Losminek] contributor entry. Co-Authored-By: Claude Opus 5.5 * i18n(cs): complete the Czech catalog (built-in styles, equation gallery) Adds the 386 keys the Russian and Spanish catalogs have and Czech did not: built-in style names and numbered families, the equation gallery, watermarks, paper sizes, table styles, preview tiles and the remaining ribbon/menu labels. The Czech coverage test now requires every Russian and Spanish key and checks built-in style names; localization-parity updated. Co-Authored-By: Claude Opus 5.5 * i18n(cs): shorter Proofing label "Kontrola pravopisu a mluvnice" overflows the fixed 170 px Options pane list into the content. "Kontrola pravopisu" fits and also reads well as the Review ribbon group name. Co-Authored-By: Claude Opus 5.5 * cs: cross-reference strings; drop paper-size rows the menu no longer uses Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/cs.tsv | 388 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n/tests.rs | 13 +- docs/localization-parity.md | 9 +- 3 files changed, 407 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d6631000..91832ea59 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1,6 +1,7 @@ # Czech (cs) interface catalog. Columns, separated by a TAB: # context English source translation -# context empty (WordCraft uses no contexts yet) +# context usually empty. `preview` is short sample text inside a rendered preview tile; +# it falls back to the plain entry. # source the English text exactly as in the code (tl!(...), ribbon labels, command labels) # escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a # trailing "…" in one needs one in the other. @@ -10,6 +11,7 @@ # Czech computing and typography vocabulary. No Microsoft Word (or other product) localisation, # string table or translated UI was consulted or copied; add entries the same way. # Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters. +# Counts use count-neutral wording (`Počet slov: 21`), so no plural forms are needed. (All) (Vše) (no style) (žádný styl) @@ -23,6 +25,7 @@ 3-D 3D A file dialog is already open. Dialogové okno souboru je již otevřené. A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Bezplatný textový procesor s otevřeným zdrojovým kódem napsaný od základu v jazyce Rust.\nSoučást sady Crafting Apps od týmu ArtCraft. + AaBbCcDd AaÁáBbCc A mail merge field Pole hromadné korespondence A numbered sequence (figures, tables…) Číslovaná posloupnost (obrázky, tabulky…) A password is required to open this document. K otevření tohoto dokumentu je vyžadováno heslo. @@ -77,6 +80,9 @@ Allow row to break across pages Povolit zalomení řádku mezi stránkami Alphabetical Abecedně Alt Text Alternativní text + Annotation Reference Odkaz na komentář + Annotation Subject Předmět komentáře + Annotation Text Text komentáře Alt text Alternativní text Alt: Alt. text: Anyone can open this document. Encrypt it with a password to keep it private. Tento dokument může otevřít kdokoli. Chcete-li jej chránit, zašifrujte jej heslem. @@ -128,8 +134,10 @@ Blank document Prázdný dokument Blank Page Prázdná stránka Block\nAuthors Blokovat\nautory + Body text in a short paragraph to show spacing. Základní text ukazuje mezery v odstavci. Body Text Základní text Bold Tučné + Book Title Název knihy Bookmark Záložka Bookmark name: Název záložky: Border Painter Štětec ohraničení @@ -158,6 +166,7 @@ Cancel Zrušit Capitalize Each Word Velká Počáteční Písmena Slov Captions Titulky + Category Kategorie Categories: Kategorie: Cell Buňka Cell Margins Okraje buňky @@ -280,6 +289,7 @@ Decrease Font Size Zmenšit velikost písma Decrease Indent Zmenšit odsazení Default Výchozí + Default Paragraph Font Standardní písmo odstavce Default tab stops: Výchozí krok tabulátoru: Define New Bullet Definovat novou odrážku Define New Multilevel List Definovat nový víceúrovňový seznam @@ -372,15 +382,20 @@ Em Dash Dlouhá pomlčka Em Space Čtverčíková mezera Emboss Reliéf + Emphasis Zdůraznění En Dash Krátká pomlčka En Space Půlčtverčíková mezera Encrypt with Password Zašifrovat heslem + Endnote Reference Znak vysvětlivky + Endnote Text Text vysvětlivky Encrypt with Password… Zašifrovat heslem… English (United States) Angličtina (Spojené státy) Engrave Rytina Enter formatting for number: Zadejte formát čísla: Enter in Equation Enter v rovnici Enter page number: Zadejte číslo stránky: + Envelope Address Adresa na obálce + Envelope Return Zpáteční adresa na obálce Envelopes Obálky Equal column width Stejná šířka sloupců Equals: Rovná se: @@ -449,6 +464,8 @@ Fonts Písma Footer Zápatí Footnote and Endnote Poznámka pod čarou a vysvětlivka + Footnote Reference Znak poznámky pod čarou + Footnote Text Text poznámky pod čarou Footnotes Poznámky pod čarou Format Formát Format Page Numbers Formát číslování stránek @@ -461,6 +478,12 @@ Formula Vzorec Fraction Zlomek from Unicode (hex) z Unicode (hex) + All - Linear Všechny – lineární + All - Professional Všechny – profesionální + Current - Linear Aktuální – lineární + Current - Professional Aktuální – profesionální + Centered as Group Na střed jako skupina + ✓ None ✓ Žádné Function Funkce General Obecné getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft @@ -501,6 +524,7 @@ Home Domů Horizontal Vodorovně Horizontal Line Vodorovná čára + Hyperlink Hypertextový odkaz How many pages the whole document has Celkový počet stránek v dokumentu How many pages this section has Počet stránek v tomto oddílu How many words the document holds Počet slov v dokumentu @@ -523,6 +547,8 @@ Indents Odsazení Indents and Spacing Odsazení a mezery Index Rejstřík + Index Heading Nadpis rejstříku + Index {n} Rejstřík {n} Index and Tables Rejstřík a seznamy Info Informace Ink Rukopis @@ -563,6 +589,9 @@ Insertions: Vložení: Inside Borders Vnitřní ohraničení Inspect Document Zkontrolovat dokument + Intense Emphasis Výrazné zdůraznění + Intense Quote Výrazná citace + Intense Reference Výrazný odkaz Inspect Styles at Selection Zkontrolovat styly výběru Integral Integrál Interface language: Jazyk rozhraní: @@ -581,6 +610,7 @@ Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Heslo si bezpečně uložte: bez něj dokument nikdo (včetně aplikace WordCraft) neotevře. Chcete-li heslo odebrat, ponechte obě pole prázdná. Keep with Next Svázat s následujícím Keep with next Svázat s následujícím + Keywords Klíčová slova Keeps the fonts and formatting of the copied text. Zachová písma a formátování zkopírovaného textu. Labels Štítky Landscape Na šířku @@ -618,6 +648,7 @@ Line breaking Zalamování řádků Line delta Změna počtu řádků Line End Konec řádku + Line Number Číslo řádku Line Numbers Čísla řádků Line Spacing Řádkování Line spacing Řádkování @@ -633,6 +664,7 @@ Link to Previous Navázat na předchozí Linked style (paragraph and character) Propojený styl (odstavec a znak) Links Odkazy + List Paragraph Odstavec se seznamem Links and References Odkazy a reference List Definition Definice seznamu Location: {path} Umístění: {path} @@ -709,6 +741,7 @@ No Markup Bez revizí No model credits were built into this copy. Tato kopie neobsahuje uvedení modelů. No Paragraph Space Bez mezer mezi odstavci + No Spacing Bez mezer No Shadow Bez stínu No Soft Edges Bez měkkých okrajů No templates match your search. Hledání neodpovídají žádné šablony. @@ -717,9 +750,12 @@ Nonbreaking Hyphen Nezlomitelný spojovník Nonbreaking Space Nezlomitelná mezera None Žádné + Normal Normální Normal 1" all Normální 1" všude + Normal Indent Normální s odsazením Normal speed Normální rychlost Normal Style Normální styl + Normal Table Normální tabulka Normal Text Normální text Normal Word Document Normální dokument Word Not available right now Momentálně nedostupné @@ -858,6 +894,7 @@ Quick Access Toolbar Panel nástrojů Rychlý přístup Quick Parts Rychlé části Quick Tables Rychlé tabulky + Quote Citace Radical Odmocnina Raised/lowered Zvýšené/snížené Read Aloud Číst nahlas @@ -955,6 +992,38 @@ Save As Uložit jako Save as Template Uložit jako šablonu Save the document to turn on AutoSave Pro zapnutí automatického ukládání dokument nejprve uložte + Contributors Přispěvatelé + Models Modely + Good evening Dobrý večer + Good morning Dobré ráno + Good afternoon Dobré odpoledne + Track\nChanges Sledovat\nzměny + Track\nChanges ✓ Sledovat\nzměny ✓ + tOGGLE cASE pŘEPNOUT vELIKOST pÍSMEN + Change to Level 1 Změnit na úroveň 1 + Change to Level 2 Změnit na úroveň 2 + Change to Level 3 Změnit na úroveň 3 + Change to Level 4 Změnit na úroveň 4 + Change to Level 5 Změnit na úroveň 5 + Change to Level 6 Změnit na úroveň 6 + Change to Level 7 Změnit na úroveň 7 + Change to Level 8 Změnit na úroveň 8 + Change to Level 9 Změnit na úroveň 9 + Level 1 Úroveň 1 + Level 2 Úroveň 2 + Level 3 Úroveň 3 + Level 4 Úroveň 4 + Level 5 Úroveň 5 + Level 6 Úroveň 6 + Level 7 Úroveň 7 + Level 8 Úroveň 8 + Level 9 Úroveň 9 + ASAP CO NEJDŘÍVE + CONFIDENTIAL DŮVĚRNÉ + DO NOT COPY NEKOPÍROVAT + DRAFT KONCEPT + SAMPLE VZOR + URGENT NALÉHAVÉ Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Pro zapnutí automatického ukládání dokument uložte (pouze otevřený soubor se nepřepisuje, dokud jej sami neuložíte) Saved Uloženo Scale Měřítko @@ -1081,6 +1150,7 @@ Stop Reading Zastavit čtení Stopped Zastaveno Strikethrough Přeškrtnuté + Strong Silné Structure Actions Akce se strukturou Structures Struktury Studio cover Titulní stránka Studio @@ -1092,7 +1162,11 @@ Style: Styl: Styles Styly Styles Pane Podokno stylů + Subject Předmět Subscript Dolní index + Subtitle Podtitul + Subtle Emphasis Jemné zdůraznění + Subtle Reference Jemný odkaz Subset: Podmnožina: Superscript Horní index Suppress line numbers Potlačit čísla řádků @@ -1108,9 +1182,11 @@ Table Tabulka Table Column Width Šířka sloupce tabulky Table Design Návrh tabulky + Table Grid Mřížka tabulky Table Layout Rozvržení tabulky Table of Authorities Seznam citovaných předpisů Table of Contents Obsah + Table of Figures Seznam obrázků Table of\nContents Obsah Table Properties Vlastnosti tabulky Table Properties… Vlastnosti tabulky… @@ -1315,6 +1391,294 @@ Lines +/− Řádky +/− Craft Řemeslo Studio Studio + Plain Table 1 Prostá tabulka 1 + Grid Table 1 Light Tabulka s mřížkou 1 – světlá + Grid Table 4 – Blue Tabulka s mřížkou 4 – modrá + Grid Table 4 – Green Tabulka s mřížkou 4 – zelená + Grid Table 4 – Orange Tabulka s mřížkou 4 – oranžová + Grid Table 4 – Purple Tabulka s mřížkou 4 – fialová + List Table 3 – Blue Tabulka se seznamem 3 – modrá + List Table 3 – Green Tabulka se seznamem 3 – zelená + List Table 3 – Orange Tabulka se seznamem 3 – oranžová + List Table 3 – Purple Tabulka se seznamem 3 – fialová + Convert between built-up and linear form Převést mezi profesionálním a lineárním tvarem + Justification of a display equation Zarovnání rovnice na samostatném řádku + Number the equation; the number sits at the right margin (or type # then the number) Očíslovat rovnici; číslo se umístí k pravému okraji (nebo napište # a poté číslo) + Show the equation in the line of text Zobrazit rovnici v řádku textu + Show the equation on a line of its own Zobrazit rovnici na samostatném řádku + Type equations in LaTeX Zapisovat rovnice v LaTeXu + Type equations in the linear format (UnicodeMath) Zapisovat rovnice v lineárním formátu (UnicodeMath) + Type normal (non-math) text in the equation Psát v rovnici běžný (nematematický) text + Sides of a right triangle Strany pravoúhlého trojúhelníku + Circle area from its radius Obsah kruhu z jeho poloměru + Roots of ax²+bx+c Kořeny ax²+bx+c + Binomial expansion of (x+a)ⁿ Binomický rozvoj (x+a)ⁿ + Power series of (1+x)ⁿ Mocninná řada (1+x)ⁿ + Euler's identity Eulerova identita + Derivative as a limit Derivace jako limita + Exponential series Exponenciální řada + Integral of e^(−x²) Integrál e^(−x²) + Sine and cosine series of f(x) Sinová a kosinová řada f(x) + Sum or difference of sines Součet nebo rozdíl sinů + Sum of cosines Součet kosinů + Normal probability density Hustota pravděpodobnosti normálního rozdělení + Bayes' rule Bayesova věta + Accents Diakritická znaménka + Basic Math Základní matematika + Boxed Formulas Vzorce v rámečku + Cases and Stacks Rozlišení případů a výrazy nad sebou + Common Accent Objects Běžné výrazy s diakritickými znaménky + Common Brackets Běžné závorky + Common Fractions Běžné zlomky + Common Functions Běžné funkce + Common Large Operators Běžné velké operátory + Common Operator Structures Běžné struktury operátorů + Common Operators Běžné operátory + Common Radicals Běžné odmocniny + Common Subscripts and Superscripts Běžné dolní a horní indexy + Contour Integrals Křivkové integrály + Differentials Diferenciály + Dots Tečky + Empty Matrices Prázdné matice + Functions Funkce + Geometry Geometrie + Greek Letters Řecká písmena + Hyperbolic Functions Hyperbolické funkce + Identity Matrices Jednotkové matice + Integrals Integrály + Inverse Functions Inverzní funkce + Inverse Hyperbolic Functions Inverzní hyperbolické funkce + Letter-Like Symbols Symboly podobné písmenům + Matrices with Brackets Matice v závorkách + Negated Relations Negované relace + Operator Structures Struktury operátorů + Operators Operátory + Other Large Operators Další velké operátory + Overbars and Underbars Čáry nad a pod výrazem + Products and Coproducts Součiny a koprodukty + Radicals Odmocniny + Scripts Indexy + Single Brackets Samostatné závorky + Sparse Matrices Řídké matice + Subscripts and Superscripts Dolní a horní indexy + Summations Sumy + Trigonometric Functions Goniometrické funkce + Unions and Intersections Sjednocení a průniky + 1×2 Empty Matrix Prázdná matice 1×2 + 1×3 Empty Matrix Prázdná matice 1×3 + 2×1 Empty Matrix Prázdná matice 2×1 + 2×2 Empty Matrix Prázdná matice 2×2 + 2×3 Empty Matrix Prázdná matice 2×3 + 3×1 Empty Matrix Prázdná matice 3×1 + 3×2 Empty Matrix Prázdná matice 3×2 + 3×3 Empty Matrix Prázdná matice 3×3 + 2×2 Determinant Determinant 2×2 + 2×2 Identity Matrix Jednotková matice 2×2 + 2×2 Identity Matrix (sparse) Jednotková matice 2×2 (řídká) + 3×3 Identity Matrix Jednotková matice 3×3 + 3×3 Identity Matrix (sparse) Jednotková matice 3×3 (řídká) + 2×2 Matrix in Brackets Matice 2×2 v hranatých závorkách + 2×2 Matrix in Double Bars Matice 2×2 mezi dvojitými svislými čarami + 2×2 Matrix in Parentheses Matice 2×2 v kulatých závorkách + n×n Identity Matrix Jednotková matice n×n + n×n Matrix Matice n×n + A xor B A XOR B + Absolute value cases Absolutní hodnota podle případů + Acute Čárka + Angle Brackets Lomené závorky + Angle Brackets with Separator Lomené závorky s oddělovačem + Angle Brackets with Two Separators Lomené závorky se dvěma oddělovači + Baseline Dots Tečky na účaří + Binomial Coefficient Binomický koeficient + Binomial Coefficient (angle) Binomický koeficient (lomené závorky) + Binomial Coefficient with Angle Brackets Binomický koeficient v lomených závorkách + Boxed Formula Vzorec v rámečku + Boxed Formula (example) Vzorec v rámečku (příklad) + Braces Složené závorky + Braces with Separator Složené závorky s oddělovačem + Brackets Hranaté závorky + Brackets with Separators Závorky s oddělovači + Breve Oblouček + Cases (Three Conditions) Rozlišení případů (tři podmínky) + Cases (Two Conditions) Rozlišení případů (dvě podmínky) + Ceiling Horní celá část + Change in y over change in x Změna y dělená změnou x + Check Háček + Circled Dot Tečka v kroužku + Circled Plus Plus v kroužku + Circled Times Znaménko násobení v kroužku + Colon Equal Dvojtečka a rovná se + Contour Integral Křivkový integrál + Contour Integral with Limits Křivkový integrál s mezemi + Contour Integral with Stacked Limits Křivkový integrál s mezemi nad a pod + Coproduct Koprodukt + Coproduct with Limits Koprodukt s mezemi + Coproduct with Lower Limit Koprodukt s dolní mezí + Coproduct with Subscript-Superscript Limits Koprodukt s mezemi v dolním a horním indexu + Cosecant Function Kosekans + Cosine 2x Kosinus 2x + Cosine Function Kosinus + Cotangent Function Kotangens + Cube Root Třetí odmocnina + Delta Equal To Rovná se s deltou + Delta over arrow Delta nad šipkou + Diagonal Dots Diagonální tečky + Differential Diferenciál + Differential theta Diferenciál théta + Differential x Diferenciál x + Differential y Diferenciál y + Dot Tečka + Double Bar Dvojitá čára + Double Dot Dvě tečky + Double Integral Dvojný integrál + Double Integral with Limits Dvojný integrál s mezemi + Double Integral with Stacked Limits Dvojný integrál s mezemi nad a pod + Double Left Arrow with Text Above Dvojitá šipka vlevo s textem nad + Double Left Arrow with Text Below Dvojitá šipka vlevo s textem pod + Double Left Right Arrow with Text Above Dvojitá obousměrná šipka s textem nad + Double Left Right Arrow with Text Below Dvojitá obousměrná šipka s textem pod + Double Right Arrow with Text Above Dvojitá šipka vpravo s textem nad + Double Right Arrow with Text Below Dvojitá šipka vpravo s textem pod + Double Square Brackets Dvojité hranaté závorky + Double Vertical Bars Dvojité svislé čáry + Double summation Dvojná suma + Equal Equal Dvě rovnítka + Equal by Definition Rovná se podle definice + Exponential Exponenciální funkce + Floor Dolní celá část + Grave Obrácená čárka + Grouping Character Above Seskupovací znak nad výrazem + Grouping Character Below Seskupovací znak pod výrazem + Hat Stříška + Hyperbolic Cosecant Function Hyperbolický kosekans + Hyperbolic Cosine Function Hyperbolický kosinus + Hyperbolic Cotangent Function Hyperbolický kotangens + Hyperbolic Secant Function Hyperbolický sekans + Hyperbolic Sine Function Hyperbolický sinus + Hyperbolic Tangent Function Hyperbolický tangens + Hypotenuse Přepona + Integral with Limits Integrál s mezemi + Integral with Stacked Limits Integrál s mezemi nad a pod + Intersection Průnik + Intersection with Limits Průnik s mezemi + Intersection with Lower Limit Průnik s dolní mezí + Intersection with Subscript-Superscript Limits Průnik s mezemi v dolním a horním indexu + Inverse Cosecant Function Arkuskosekans + Inverse Cosine Function Arkuskosinus + Inverse Cotangent Function Arkuskotangens + Inverse Hyperbolic Cosecant Function Inverzní hyperbolický kosekans + Inverse Hyperbolic Cosine Function Inverzní hyperbolický kosinus + Inverse Hyperbolic Cotangent Function Inverzní hyperbolický kotangens + Inverse Hyperbolic Secant Function Inverzní hyperbolický sekans + Inverse Hyperbolic Sine Function Inverzní hyperbolický sinus + Inverse Hyperbolic Tangent Function Inverzní hyperbolický tangens + Inverse Secant Function Arkussekans + Inverse Sine Function Arkussinus + Inverse Tangent Function Arkustangens + Left Arrow with Text Above Šipka vlevo s textem nad + Left Arrow with Text Below Šipka vlevo s textem pod + Left Bracket Twice Dvě levé hranaté závorky + Left Right Arrow Above Obousměrná šipka nad výrazem + Left Right Arrow with Text Above Obousměrná šipka s textem nad + Left Right Arrow with Text Below Obousměrná šipka s textem pod + Left Subscript-Superscript Levý dolní a horní index + Leftwards Arrow Above Šipka vlevo nad výrazem + Leftwards Harpoon Above Harpuna vlevo nad výrazem + Limit Limita + Limit Example Limita (příklad) + Linear Fraction Lineární zlomek + Logarithm Logaritmus + Logarithm with Base Logaritmus se základem + Logical AND Logické A + Logical AND with Limits Logické A s mezemi + Logical OR Logické NEBO + Logical OR with Limits Logické NEBO s mezemi + Maximum Maximum + Maximum Example Maximum (příklad) + Measured By Měřeno pomocí + Midline Dots Tečky ve střední výšce + Minimum Minimum + Minus Equal Minus a rovná se + Natural Logarithm Přirozený logaritmus + One half Jedna polovina + Outward Brackets Závorky otočené ven + Overbar Čára nad výrazem + Overbrace Složená závorka nad výrazem + Parentheses Kulaté závorky + Parentheses with Separator Kulaté závorky s oddělovačem + Partial Differential Parciální diferenciál + Pi Over 2 Pí děleno 2 + Plus Equal Plus a rovná se + Product Součin + Product of A Součin A + Product with Limits Součin s mezemi + Product with Lower Limit Součin s dolní mezí + Product with Subscript-Superscript Limits Součin s mezemi v dolním a horním indexu + Quadratic Root Kořen kvadratické rovnice + Radical with Degree Odmocnina se stupněm + Right Arrow with Text Above Šipka vpravo s textem nad + Right Arrow with Text Below Šipka vpravo s textem pod + Right Bracket Twice Dvě pravé hranaté závorky + Rightwards Arrow Above Šipka vpravo nad výrazem + Rightwards Harpoon Above Harpuna vpravo nad výrazem + Secant Function Sekans + Sine Function Sinus + Sine theta Sinus théta + Single Left Angle Levá lomená závorka + Single Left Bar Levá svislá čára + Single Left Brace Levá složená závorka + Single Left Bracket Levá hranatá závorka + Single Left Parenthesis Levá kulatá závorka + Single Right Brace Pravá složená závorka + Single Right Bracket Pravá hranatá závorka + Single Right Parenthesis Pravá kulatá závorka + Skewed Fraction Šikmý zlomek + Small change Malá změna + Square Root Druhá odmocnina + Square Root with Degree Druhá odmocnina se stupněm + Square Union Hranaté sjednocení + Stack Object Dva výrazy nad sebou + Stack Object (Three) Tři výrazy nad sebou + Stacked Fraction Zlomek s vodorovnou čarou + Stacked, no bar Nad sebou bez zlomkové čáry + Subscript-Superscript Dolní a horní index + Summation Suma + Summation from 0 to n Suma od 0 do n + Summation over binomials Suma binomických koeficientů + Summation with Limits Suma s mezemi + Summation with Lower Limit Suma s dolní mezí + Summation with Subscript Limit Suma s mezí v dolním indexu + Summation with Subscript-Superscript Limits Suma s mezemi v dolním a horním indexu + Surface Integral Plošný integrál + Surface Integral with Limits Plošný integrál s mezemi + Surface Integral with Stacked Limits Plošný integrál s mezemi nad a pod + Tangent Function Tangens + Tangent formula Vzorec pro tangens + Text over Colon Equal Text nad dvojtečkou a rovnítkem + Text over Equal Text nad rovnítkem + Tilde Vlnovka + Triple Dot Tři tečky + Triple Integral Trojný integrál + Triple Integral with Limits Trojný integrál s mezemi + Triple Integral with Stacked Limits Trojný integrál s mezemi nad a pod + Underbar Čára pod výrazem + Underbrace Složená závorka pod výrazem + Union Sjednocení + Union of intersections Sjednocení průniků + Union with Limits Sjednocení s mezemi + Union with Lower Limit Sjednocení s dolní mezí + Union with Plus Sjednocení s plusem + Union with Subscript-Superscript Limits Sjednocení s mezemi v dolním a horním indexu + Up Diagonal Dots Diagonální tečky nahoru + Vector A Vektor A + Vector AB Vektor AB + Vertical Bars Svislé čáry + Vertical Dots Svislé tečky + Volume Integral Objemový integrál + Volume Integral with Limits Objemový integrál s mezemi + Volume Integral with Stacked Limits Objemový integrál s mezemi nad a pod + Yields Dává + x bar x s čárou nad Atelier Ateliér Darkroom Temná komora Gallery Galerie @@ -1597,6 +1961,10 @@ Tag Značka This page Tato stránka Title Název + TOA Heading Nadpis seznamu citovaných předpisů + TOC Heading Nadpis obsahu + TOC {n} Obsah {n} +preview Title Ukázka Type: Typ: URL URL Volume Ročník @@ -1875,6 +2243,7 @@ Tab characters Znaky tabulátoru These marks show even when Show/Hide ¶ is off. Tyto značky se zobrazují, i když je vypnuto Zobrazit/skrýt ¶. Typing replaces selected text Psaný text nahrazuje vybraný text + Typing Psaní Update fields before printing Před tiskem aktualizovat pole Use the Insert key to switch overtype on and off Přepínat režim přepisování klávesou Insert Extend Selection by Units Rozšířit výběr po jednotkách @@ -2016,6 +2385,7 @@ Outline Tools Nástroje osnovy Close Outline\nView Zavřít\nzobrazení osnovy Use wildcards Používat zástupné znaky + Wildcards (regex) Zástupné znaky (regex) Special Speciální Match prefix Shoda předpony Match suffix Shoda přípony @@ -2057,6 +2427,15 @@ Your system has animation effects turned off. Efekty animace jsou v systému vypnuté. Your system doesn't report an animation setting, so animation effects stay on. Systém neuvádí žádné nastavení animací, proto efekty animace zůstávají zapnuté. Do Not Check Spelling or Grammar Nekontrolovat pravopis ani mluvnici + ➕ New comment ➕ Nový komentář + 📂 Browse… 📂 Procházet… + Possible spelling mistake Možná pravopisná chyba + Extra space Nadbytečná mezera + Capitalize the first word of a sentence Začít první slovo věty velkým písmenem + Remove the space before the punctuation Odstranit mezeru před interpunkcí + Use "an" before a vowel sound Před samohláskovým zvukem použít „an“ + Use "a" before a consonant sound Před souhláskovým zvukem použít „a“ + The spelling and grammar check is complete. Kontrola pravopisu a mluvnice je dokončena. Undo Actions Vrátit více akcí Undo History Historie vrácení Undo 1 Action Vrátit 1 akci @@ -2134,3 +2513,10 @@ Suggested Navrhované Set Proofing Language… Nastavit jazyk kontroly pravopisu… Language Preferences… Předvolby jazyka… + +# Cross-reference dialog (#1003) + For which item: Pro kterou položku: + Heading Nadpis + Heading text Text nadpisu + Insert reference to: Vložit odkaz na: + Reference type: Typ odkazu: diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e9bd531fb..ed7460ec2 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -377,7 +377,11 @@ fn czech_locales_and_saved_preference_work_without_changing_the_document() { fn czech_covers_the_entire_existing_interface_catalog() { use std::collections::HashSet; let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - assert_eq!(keys(lang("cs").0.source), keys(lang("zh-hans").0.source)); + // Keep the original Chinese coverage and include the complete styles, equation gallery, + // paper sizes and watermarks from the Russian and Spanish catalogs. + assert!(keys(lang("cs").0.source).is_superset(&keys(lang("zh-hans").0.source))); + assert!(keys(lang("cs").0.source).is_superset(&keys(lang("ru").0.source))); + assert!(keys(lang("cs").0.source).is_superset(&keys(lang("es").0.source))); let cs = lang("cs"); assert_eq!(tr(cs, "Home"), "Domů"); assert_eq!(tr(cs, "Font"), "Písmo"); @@ -391,6 +395,13 @@ fn czech_covers_the_entire_existing_interface_catalog() { assert_eq!(fmt(tr(cs, "{words} words"), &[("words", &words)]), format!("Počet slov: {count}")); assert_eq!(fmt(tr(cs, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Počet slov: {words}; vybráno: 1")); } + set_current(cs); + // Built-in names and numbered style families use Czech; user-defined names stay as written. + for (style, expected) in [("Normal", "Normální"), ("No Spacing", "Bez mezer"), ("Heading 2", "Nadpis 2"), ("TOC 3", "Obsah 3")] { + assert_eq!(builtin_style_name(style), expected); + } + assert_eq!(builtin_style_name("My Own Style"), "My Own Style"); + set_current(Lang::EN); } #[test] diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3efd52834..71174c779 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -76,6 +76,12 @@ Hungarian follows `hu-HU` system locales and `{"value":"hu"}`, with a complete c the Russian catalog, built-in style names shown translated, plus the citation style names. Hungarian proofing is not included; terminology and conventions: [`localization-hu.md`](localization-hu.md). +Czech follows `cs-CZ` system locales and `{"value":"cs"}`, with 2,447 entries: every key of the +Russian and Spanish catalogs, including built-in style names and numbered families, the equation +gallery, watermarks, paper sizes and preview tiles. The Czech coverage test +enforces both reference catalogs and retains the Chinese catalog coverage; counts keep the existing +count-neutral wording (`Počet slov: 21`). Czech proofing is not included. + Polish is the only one besides English with proofing tools, and a Polish interface writes new documents in Polish (`pl-PL`). @@ -171,6 +177,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Czech interface catalog completed: 386 entries added (2,447 total), including built-in style names, numbered families, equation gallery, watermarks, paper sizes and preview tiles; coverage test requires every Russian and Spanish key and retains Chinese coverage | | 2026-10-12 | minor | Italian proofing: it-IT spelling from the installed Hunspell dictionary (system `it_IT`, never bundled — upstream is GPL), accent-first suggestions, conservative grammar with Italian messages, TeX Italian hyphenation patterns (MIT option); Review › Language already offered Italian; an `it` interface writes it-IT documents | | 2026-10-13 | minor | Interface audit back in CI (#1128): Envelopes, Labels and Layout › Size labels go through translated patterns with the region's decimal separator (the old fixed-format paper rows, dead since #838, removed); 20 strings added to all 18 catalogs (status-bar messages, Reading Mode toolbar, Go To, Search's Suggested); citation style names and `bad parameters:` errors exempt; the Read Aloud player no longer draws a speech error before the frame has polled it, the audit's rare failure under load | | 2026-10-11 | minor | Turkish interface catalog completed (2,325 entries, the same keys as Russian): equation gallery, built-in style names, watermarks, paper sizes and preview tiles; the interface audit checks Turkish too (#774) | From 68501de224ac5d53ab62f95988bec44f6a7b7661 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:47:46 +0200 Subject: [PATCH 110/199] One Page and the zoom are saved with the document and restored when it is reopened (#1023) Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 23 +++++++++++++++++++++++ crates/docx/src/read/mod.rs | 12 +++++++++++- crates/docx/src/write/mod.rs | 14 +++++++++++++- crates/docx/tests/roundtrip.rs | 25 +++++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 3 +++ crates/engine/src/cmd/view.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/session.rs | 4 +++- crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 8 files changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c785c7ed7..3132ad6d6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -480,6 +480,10 @@ pub struct Settings { pub attached_template: Option, /// Update the document's styles from the attached template when it is opened (`w:linkStyles`). pub link_styles: bool, + /// The zoom the document was saved at (`w:zoom`), restored when it is opened; `None` for 100% + /// without a fit mode. + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom: Option, /// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376 /// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out /// the document's creator and last-modified-by. The open document keeps its real values. @@ -491,6 +495,24 @@ pub struct Settings { pub remove_date_time: bool, } +/// A saved zoom: a percentage, or a fit mode (the percentage it last came to). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Zoom { + /// 10–500. + pub percent: u32, + pub fit: Option, +} + +/// View › Zoom's fit modes a document can be saved in. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ZoomFit { + OnePage, + PageWidth, + TextWidth, +} + /// Word's compatibility mode for documents that don't state one. pub const LEGACY_COMPAT_MODE: u32 = 12; @@ -566,6 +588,7 @@ impl Default for Settings { bib_style: None, attached_template: None, link_styles: false, + zoom: None, remove_personal_info: false, remove_date_time: false, } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index a9bd6d8b7..f996a95f0 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -22,7 +22,7 @@ use wordcraft_doc::para::OBJ; use wordcraft_doc::props::Rgb; use wordcraft_doc::section::NumFormat; use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts}; -use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block}; +use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block}; use crate::DocxError; use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt}; @@ -1049,6 +1049,16 @@ impl Reader<'_> { s.endnote_pr = props::note_pr(k, false); } "w:documentProtection" => read_protection(k, s), + "w:zoom" => { + let percent = k.attr("w:percent").and_then(|v| u32_of(v.trim_end_matches('%'))).unwrap_or(100).clamp(10, 500); + let fit = match k.attr("w:val") { + Some("fullPage") => Some(ZoomFit::OnePage), + Some("bestFit") => Some(ZoomFit::PageWidth), + Some("textFit") => Some(ZoomFit::TextWidth), + _ => None, + }; + s.zoom = Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some()); + } _ => {} } } diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 81f9005f9..cd0c6e312 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -892,7 +892,19 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O let ns = xml::body_ns(); let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); w.open("w:settings", &ns_refs); - w.empty("w:zoom", &[("w:percent", "100")]); + let zoom = s.zoom.unwrap_or(wordcraft_doc::Zoom { percent: 100, fit: None }); + let percent = zoom.percent.clamp(10, 500).to_string(); + match zoom.fit { + Some(fit) => { + let val = match fit { + wordcraft_doc::ZoomFit::OnePage => "fullPage", + wordcraft_doc::ZoomFit::PageWidth => "bestFit", + wordcraft_doc::ZoomFit::TextWidth => "textFit", + }; + w.empty("w:zoom", &[("w:val", val), ("w:percent", &percent)]); + } + None => w.empty("w:zoom", &[("w:percent", &percent)]), + } // §17.15.1.70 / §17.15.1.69, right after `w:zoom` in CT_Settings. if s.remove_personal_info { w.empty("w:removePersonalInformation", &[]); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index f5ab84fab..cfcacb6c3 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1195,6 +1195,31 @@ fn attached_template_and_link_styles_round_trip() { assert!(!plain.settings.link_styles); } +/// #995: the zoom a document was saved at (`w:zoom`), One Page (`fullPage`) included, is read +/// and written back. +#[test] +fn zoom_round_trips() { + let settings = |bytes: &[u8]| { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("word/settings.xml").unwrap(), &mut xml).unwrap(); + xml + }; + let plain = wordcraft_docx::write(&Document::new()).unwrap(); + let xml = settings(&plain); + assert!(xml.contains(r#""#), "{xml}"); + for (zoom, written) in [ + (r#""#, r#""#), + (r#""#, r#""#), + (r#""#, r#""#), + (r#""#, r#""#), + ] { + let file = replace_part(&plain, "word/settings.xml", xml.replace(r#""#, zoom).as_bytes()); + let back = settings(&wordcraft_docx::write(&wordcraft_docx::read(&file).unwrap()).unwrap()); + assert!(back.contains(written), "{zoom}: {back}"); + } +} + #[test] fn settings_core_theme_round_trip() { let mut d = Document::new(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c4212a5cc..78d3d45df 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -217,6 +217,9 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { doc.styles.default_chr.lang = Some(lang.clone()); } s.set_document(doc); + if let Some(z) = s.doc.settings.zoom { + super::view::restore_zoom(s, z); + } s.path = Some(path.into()); // Like Word, a document opened with a password is saved with it again. if encrypted { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index be0477a63..d79d0a905 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,6 +1,7 @@ //! View tab and status bar: views, zoom, show/hide, panes. use serde_json::{Value, json}; +use wordcraft_doc::{Zoom, ZoomFit}; use wordcraft_layout::{NumeralMode, ViewMode}; use super::sel_result; @@ -172,6 +173,32 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"spacing": spacing})) } +/// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%. +pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option { + let fit = match v.fit.as_str() { + "onePage" => Some(ZoomFit::OnePage), + "pageWidth" => Some(ZoomFit::PageWidth), + "textWidth" => Some(ZoomFit::TextWidth), + _ => None, + }; + let percent = if v.zoom.is_finite() { (v.zoom * 100.0).round().clamp(10.0, 500.0) as u32 } else { 100 }; + Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some()) +} + +/// Show an opened document at the zoom it was saved with. +pub(crate) fn restore_zoom(s: &mut Session, z: Zoom) { + let fit = match z.fit { + Some(ZoomFit::OnePage) => "onePage", + Some(ZoomFit::PageWidth) => "pageWidth", + Some(ZoomFit::TextWidth) => "textWidth", + None => "", + }; + s.view.zoom = z.percent.clamp(10, 500) as f32 / 100.0; + s.view.fit = fit.into(); + s.view.multi_page = false; + s.view.single_column = fit == "onePage" || fit == "pageWidth"; +} + fn zoom(s: &mut Session, v: &Value) -> CmdResult { match v.get("value") { Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 6387eac8b..c0ed2d808 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -869,8 +869,10 @@ impl Session { } /// Stamp the document's save metadata (modified time, last modified by, revision; created /// when it has none) the way Word does on every save. `file.save` calls it, and so does any - /// front end that writes the bytes itself (the web build's download, #262). + /// front end that writes the bytes itself (the web build's download, #262). Like Word, the + /// document also keeps the zoom it is shown at (#995). pub fn stamp_save(&mut self) { + self.doc.settings.zoom = crate::cmd::view::saved_zoom(&self.view); let core = &mut self.doc.core; core.modified = crate::cmd::now_iso(); if core.created.is_empty() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bc4619e1f..19b269cf1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3418,6 +3418,37 @@ fn zoom_after_one_page_keeps_single_column() { assert!(!s.view.single_column); } +/// Issue #995: One Page is saved with the document (`w:zoom`, like Word) and restored when it is +/// reopened; changing the zoom alone doesn't make the document unsaved. +#[test] +fn one_page_is_restored_when_the_document_is_reopened() { + let dir = std::env::temp_dir().join(format!("wordcraft-engine-zoom-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("one-page.docx"); + let path = path.to_string_lossy(); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "file.save", json!({"path": path})); + run(&mut s, "view.onePage", json!({})); + assert!(!s.dirty, "a zoom isn't an edit"); + run(&mut s, "file.save", json!({})); + + let mut other = Session::new(wordcraft_doc::Document::new()); + run(&mut other, "file.open", json!({"path": path})); + assert_eq!(other.view.fit, "onePage"); + assert!(other.view.single_column && !other.view.multi_page); + assert!(!other.dirty); + + // A percentage is restored as a percentage, the fit mode dropped. + run(&mut other, "view.zoom", json!({"value": 80})); + run(&mut other, "file.save", json!({})); + run(&mut s, "file.open", json!({"path": path})); + assert!(s.view.fit.is_empty() && !s.view.single_column); + assert!((s.view.zoom - 0.8).abs() < 1e-6); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn navigation_pane_opens_without_changing_view_mode() { // Reading Mode's heading navigation is the Navigation pane; Outline is an editing view (#493). From e486f7c39d1ca4784fd901c780600f8d147f2539 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:47:59 +0200 Subject: [PATCH 111/199] DOCX: keep Office 2016 charts (cx:chart) and their data when saving (#1029) An mc:AlternateContent whose Choice holds a cx:chart is read as a read-only chart drawn from its fallback picture, keeping both branches and every part they reach (chartEx part, style, colours, workbook, picture). Saving writes the AlternateContent back with the chart's current frame on both branches, instead of only the fallback picture. Co-authored-by: Brandon Thomas --- crates/docx/src/read/story.rs | 29 +++++++++++- crates/docx/src/write/story.rs | 41 ++++++++++++----- crates/docx/src/xml.rs | 10 +++++ crates/docx/tests/fixtures.rs | 82 ++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 12 deletions(-) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index d30270b2d..1c28338a5 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -21,6 +21,8 @@ use wordcraft_doc::wordart::TextWarp; const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"; const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml"; +/// The `a:graphicData` URI of an Office 2016 chart (`cx:chart`). +const CHARTEX_URI: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex"; /// Deepest table nesting we build (deeper tables become plain paragraphs). const MAX_TABLE_DEPTH: usize = 24; @@ -518,7 +520,7 @@ impl Reader<'_> { } } "mc:AlternateContent" => { - if let Some(o) = self.read_ink_alternate(sc, k, rels) { + if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) { let props = self.run_props_none(ctx); self.emit_obj(sc, pb, o, &props); } else if let Some(c) = children_of_choice(k) { @@ -687,7 +689,7 @@ impl Reader<'_> { } "w:ruby" => self.read_ruby(sc, pb, k, props), "mc:AlternateContent" => { - if let Some(o) = self.read_ink_alternate(sc, k, rels) { + if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) { self.emit_obj(sc, pb, o, props); } else if let Some(c) = children_of_choice(k) { for x in c.els() { @@ -1011,6 +1013,29 @@ impl Reader<'_> { Some(object) } + /// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its + /// fallback picture, keeping both branches so saving writes the chart back rather than only + /// the picture. `None` (read the fallback alone) when it can't be kept whole. + fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option { + let choice = ac + .children("mc:Choice") + .find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?; + // A requirement in a namespace we can't name couldn't be written back. + let requires = choice.attr("Requires").unwrap_or(""); + if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") { + return None; + } + let fallback = ac.child("mc:Fallback")?; + let kept = El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] }; + let source = self.keep_embedded(&kept, rels, &[])?; + let Some(InlineObject::Image { media, w, h, alt, float, .. }) = self.read_drawing(sc, fallback.child("w:drawing")?, rels) else { + return None; + }; + let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media }]; + let graphic = Arc::new(Graphic { kind: GraphicKind::Chart, items, w, h, source: Some(source), ..Default::default() }); + Some(InlineObject::Graphic { w, h, alt, float, graphic }) + } + fn read_ink_content_part(&mut self, content: &El, rels: &Rels) -> Option { let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?; let pkg = self.pkg; diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 702c94413..6b7e04e6b 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -544,12 +544,16 @@ impl Writer<'_> { if ink && !direct_ink { Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); } - let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root); + // Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture. + let alternate = (root.name == "mc:AlternateContent").then_some(&root); let fallback = alternate.and_then(|e| e.child("mc:Fallback")); - let native = alternate.and_then(|e| e.child("mc:Choice")).and_then(|e| e.find("a:graphic")); + let choice = alternate.and_then(|e| e.child("mc:Choice")); + let native = choice.and_then(|e| e.find("a:graphic")); if alternate.is_some() && native.is_none() { return; } + // A chart's picture gets the chart's current frame too; ink's VML preview is resized in place. + let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) }; self.rev_open(w, props); w.open("w:r", &[]); self.rpr(w, props); @@ -557,24 +561,30 @@ impl Writer<'_> { w.raw(&root.to_xml()); } else { if fallback.is_some() { + let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() }; w.open("mc:AlternateContent", &[]); - w.open("mc:Choice", &[("Requires", "wpi")]); + w.open("mc:Choice", &[("Requires", requires)]); } - w.open("w:drawing", &[]); let docpr = self.next_docpr(); let name = match graphic.kind { wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), }; - self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt); - w.empty("wp:cNvGraphicFramePr", &[]); - w.raw(&native.unwrap_or(&root).to_xml()); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); + let z = self.z; + self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root)); if let Some(fallback) = fallback { w.close("mc:Choice"); - w.raw(&fallback.to_xml()); + match picture { + Some(picture) => { + // Both branches are one object: same id and stacking order. + self.z = z; + w.open("mc:Fallback", &[]); + self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture); + w.close("mc:Fallback"); + } + None => w.raw(&fallback.to_xml()), + } w.close("mc:AlternateContent"); } } @@ -1073,6 +1083,17 @@ impl Writer<'_> { } #[allow(clippy::too_many_arguments)] + /// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place. + #[allow(clippy::too_many_arguments)] + fn graphic_frame(&mut self, w: &mut W, float: &Float, (gw, gh): (f32, f32), docpr: &str, name: &str, alt: &str, graphic: &crate::xml::El) { + w.open("w:drawing", &[]); + self.drawing_open(w, float, gw, gh, docpr, name, alt); + w.empty("wp:cNvGraphicFramePr", &[]); + w.raw(&graphic.to_xml()); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + } + fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) { let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0))); if float.wrap == Wrap::Inline { diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 2d4747841..1ebb497a8 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -38,6 +38,16 @@ pub const NAMESPACES: &[(&str, &str)] = &[ ("v", "urn:schemas-microsoft-com:vml"), ("o", "urn:schemas-microsoft-com:office:office"), ("w10", "urn:schemas-microsoft-com:office:word"), + // Office 2016 charts (`cx:chart`), and the namespaces their `mc:Choice` requires. + ("cx", "http://schemas.microsoft.com/office/drawing/2014/chartex"), + ("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"), + ("cx2", "http://schemas.microsoft.com/office/drawing/2015/10/21/chartex"), + ("cx3", "http://schemas.microsoft.com/office/drawing/2016/5/9/chartex"), + ("cx4", "http://schemas.microsoft.com/office/drawing/2016/5/10/chartex"), + ("cx5", "http://schemas.microsoft.com/office/drawing/2016/5/11/chartex"), + ("cx6", "http://schemas.microsoft.com/office/drawing/2016/5/12/chartex"), + ("cx7", "http://schemas.microsoft.com/office/drawing/2016/5/13/chartex"), + ("cx8", "http://schemas.microsoft.com/office/drawing/2016/5/14/chartex"), ]; /// The `a:ext` URI under which a drawing is marked decorative ([MS-ODRAWXML]), and the diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 6b46052b7..471a1bebe 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -928,6 +928,88 @@ fn chart_round_trips_with_its_parts() { check_chart(&again, &ids[0]); } +// ---- Office 2016 charts (cx:chart) written back with their fallback picture (#997) ---- + +const CHARTEX_NS: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex"; +const CHARTEX_XML: &str = r#""#; +const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml"; + +/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an +/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`. +fn chartex_docx(requires: &str, requires_ns: &str) -> Vec { + let frame = r#""#; + let body = format!( + r#"Spread{frame}{frame}"# + ); + let doc = format!(r#"{body}"#); + let doc_rels = r#""#; + let chart_rels = r#""#; + let types = format!( + r#""# + ); + zip(&[ + ("[Content_Types].xml", types.as_bytes()), + ("_rels/.rels", ROOT_RELS.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/_rels/document.xml.rels", doc_rels.as_bytes()), + ("word/charts/chartEx1.xml", CHARTEX_XML.as_bytes()), + ("word/charts/_rels/chartEx1.xml.rels", chart_rels.as_bytes()), + ("word/charts/colors1.xml", COLORS_XML.as_bytes()), + ("word/embeddings/Microsoft_Excel_Worksheet.xlsx", WORKBOOK.as_bytes()), + ("word/media/image1.png", b"fallback picture bytes"), + ]) +} + +#[test] +fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() { + // Word binds `cx1`… to the chart type's own namespace; Apache POI's sample binds `cx`. + for (requires, requires_ns) in [("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"), ("cx", CHARTEX_NS)] { + let mut d = wordcraft_docx::read(&chartex_docx(requires, requires_ns)).unwrap(); + for _ in 0..2 { + let g = graphics(&d); + let [InlineObject::Graphic { w, graphic, .. }] = g.as_slice() else { panic!("{requires}: {g:?}") }; + assert_eq!(*w, 216.0); + assert!( + graphic.items.iter().any(|i| matches!(i, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media))), + "{requires}: the chart shows its fallback picture" + ); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + let body = text(&files, "word/document.xml"); + assert_eq!(attr_values(&body, "mc:Choice", "Requires"), [requires], "{body}"); + assert!(body.contains(&format!(r#"xmlns:{requires}="{requires_ns}""#)), "{requires} is declared: {body}"); + let doc_rels = text(&files, "word/_rels/document.xml.rels"); + let [cx_id] = attr_values(&body, "cx:chart", "r:id").try_into().unwrap_or_else(|_| panic!("chart lost: {body}")); + let (kind, target) = rel(&doc_rels, &cx_id); + assert!(kind.ends_with("/chartEx"), "{kind}"); + let chart = part_at("word", &target); + assert_eq!(text(&files, &chart), CHARTEX_XML); + let types = text(&files, "[Content_Types].xml"); + assert!(types.contains(&format!(r#"PartName="/{chart}" ContentType="{CT_CHARTEX}""#)), "{types}"); + let (dir, file) = chart.rsplit_once('/').unwrap(); + let (_, wb) = rel(&text(&files, &format!("{dir}/_rels/{file}.rels")), "rId1"); + assert_eq!(files.get(&part_at(dir, &wb)).map(Vec::as_slice), Some(WORKBOOK.as_bytes())); + let fallback = body.split("").nth(1).unwrap_or_else(|| panic!("no fallback: {body}")); + let [pic_id] = attr_values(fallback, "a:blip", "r:embed").try_into().unwrap_or_else(|_| panic!("fallback picture lost: {body}")); + assert_eq!(files.get(&part_at("word", &rel(&doc_rels, &pic_id).1)).map(Vec::as_slice), Some(&b"fallback picture bytes"[..])); + assert!(attr_values(&body, "wp:docPr", "name").iter().all(|n| n.starts_with("Chart ")), "{body}"); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + d = wordcraft_docx::read(&zip(&entries)).unwrap(); + } + } +} + +#[test] +fn resized_office_2016_chart_resizes_its_fallback_too() { + let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap(); + if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) + && let Some(InlineObject::Graphic { w, h, .. }) = p.objects.first_mut() + { + (*w, *h) = (288.0, 144.0); + } + let body = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/document.xml"); + assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"], "{body}"); +} + #[test] fn moved_and_copied_chart_keeps_its_parts() { let d = wordcraft_docx::read(&chart_docx()).unwrap(); From 85dd93a2bab4cf68fc6e0e21e1dc7e6bdc33880f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 21:48:19 +0200 Subject: [PATCH 112/199] Table eraser: borders beside vertically merged cells merge as in Word (#1039) (#1044) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/engine/src/cmd/table_draw.rs | 175 ++++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 180b0976f..b97e41c37 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -185,6 +185,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing | | 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. | | 2026-10-12 | A DOCX hyperlink whose relationship has an empty target (as LibreOffice writes) is no link (#1005): no "Ctrl+Click to follow link" tooltip over the text, which keeps its Hyperlink style | +| 2026-10-12 | Table eraser beside vertically merged cells (#1039): the border between two cells merged down the same rows merges them into one cell that stays merged down those rows; a border whose two sides can't make one rectangle is hidden on both sides along the whole edge instead of refused | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-12 | `--help` and `-h` print the usage and exit (#968): `wordcraft --help` lists the app's options without opening a window (it used to try to open a file named `--help`), and `wordcraft-cli --help` works for every command instead of failing with `unknown option --help` | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | diff --git a/crates/engine/src/cmd/table_draw.rs b/crates/engine/src/cmd/table_draw.rs index 532436cc7..a5102ea7c 100644 --- a/crates/engine/src/cmd/table_draw.rs +++ b/crates/engine/src/cmd/table_draw.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { r#"{"page": n, "x0": pt, "y0": pt, "x1": pt, "y1": pt, "story"?: StoryRef} (a pen stroke in page points, 0-based page: a rectangle at least 12 pt each way outside any table inserts a one-cell table of that size at the paragraph under its start; a straight vertical or horizontal line inside a table splits the cells it crosses there. In the app the button turns the pen on.)"#, ), CommandSpec::new("table.eraser", "Eraser", "Table Layout › Draw", eraser).params( - r#"{"page": n, "x": pt, "y": pt} (removes the border between two cells within 4 pt of the point by merging them. In the app the button turns the eraser on.)"#, + r#"{"page": n, "x": pt, "y": pt} (removes the border between two cells within 4 pt of the point: cells that make one rectangle merge, including cells merged down the same rows; otherwise the border is hidden along the whole edge. In the app the button turns the eraser on.)"#, ), ] } @@ -191,6 +191,90 @@ fn vgroup(t: &Table, r: usize, c: usize) -> (usize, usize) { (r0, r1) } +/// A cell as the grid rectangle it covers: rows `top..=bot`, grid columns `g0..g1`. +#[derive(Clone, Copy, Debug)] +struct Logical { + top: usize, + bot: usize, + g0: usize, + g1: usize, +} + +fn logical(t: &Table, r: usize, c: usize) -> Logical { + let g0 = t.grid_col(r, c); + let span = t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, Cell::span); + let (top, bot) = vgroup(t, r, c); + Logical { top, bot, g0, g1: g0 + span } +} + +/// The rows (`top`, `bot`) two cells side by side share when erasing the border between them +/// leaves one rectangle: every row of both has exactly their cells there, merged the same way. +fn aligned_rows(t: &Table, a: &Logical, b: &Logical) -> Option<(usize, usize)> { + if a.top != b.top || a.bot != b.bot || a.g1 != b.g0 { + return None; + } + for row in a.top..=a.bot { + let ci = t.cell_at_grid(row, a.g0).filter(|&ci| t.grid_col(row, ci) == a.g0)?; + let cells = &t.rows.get(row)?.cells; + let (x, y) = (cells.get(ci)?, cells.get(ci + 1)?); + if x.span() != a.g1 - a.g0 || y.span() != b.g1 - b.g0 || x.props.vmerge != y.props.vmerge { + return None; + } + } + Some((a.top, a.bot)) +} + +/// Hide the border line on both sides along an edge: set no border on every cell beside it. +/// `vertical`: the line sits left of grid column `line` and the edge covers rows `lo..=hi`; +/// otherwise it sits above row `line` and covers grid columns `lo..hi`. +fn hide_edge(t: &mut Table, vertical: bool, line: usize, (lo, hi): (usize, usize)) { + let nil = wordcraft_doc::props::Border { style: wordcraft_doc::props::BorderStyle::None, width: 0.0, color: None, space: 0.0 }; + // Only a cell whose edge lies on the line is touched. + let set_v = |t: &mut Table, r: usize, g: usize, before: bool| { + let Some(ci) = t.cell_at_grid(r, g) else { return }; + let start = t.grid_col(r, ci); + let Some(cell) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(ci)) else { return }; + let end = start + cell.span(); + let b = cell.props.borders.get_or_insert_with(Default::default); + if before && end == line { + b.right = Some(nil); + } else if !before && start == line { + b.left = Some(nil); + } + }; + let set_h = |t: &mut Table, r: usize, g: usize, above: bool| { + let Some(ci) = t.cell_at_grid(r, g) else { return }; + let Some(cell) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(ci)) else { return }; + let b = cell.props.borders.get_or_insert_with(Default::default); + if above { + b.bottom = Some(nil); + } else { + b.top = Some(nil); + } + }; + if vertical { + for r in lo..=hi.min(t.rows.len().saturating_sub(1)) { + if let Some(l) = line.checked_sub(1) { + set_v(t, r, l, true); + } + set_v(t, r, line, false); + } + } else { + let below_cont = |t: &Table, g: usize| { + t.cell_at_grid(line, g).and_then(|ci| t.rows.get(line)?.cells.get(ci)).is_some_and(|c| c.props.vmerge == VMerge::Continue) + }; + for g in lo..hi.min(MAX_COLS) { + if below_cont(t, g) { + continue; + } + if let Some(above) = line.checked_sub(1) { + set_h(t, above, g, true); + } + set_h(t, line, g, false); + } + } +} + /// A vertical line at page `x`: split each crossed cell into two columns there. fn split_columns(s: &mut Session, l: &DocLayout, page: usize, x: f32, (ya, yb): (f32, f32)) -> CmdResult { let boxes = cell_boxes(l, page); @@ -439,20 +523,32 @@ fn eraser(s: &mut Session, v: &Value) -> CmdResult { let t = s.doc.table_mut(story, &tp)?; let ((r, c), (r2, c2)) = (e.first, e.second); let cell = |t: &Table, r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map(|x| (x.props.vmerge, x.span())); - let (Some((va, sa)), Some((vb, sb))) = (cell(t, r, c), cell(t, r2, c2)) else { + let (Some((_, sa)), Some((vb, sb))) = (cell(t, r, c), cell(t, r2, c2)) else { return Err(CmdError::Failed("the cells aren't there any more".into())); }; + let (la, lb) = (logical(t, r, c), logical(t, r2, c2)); if e.across { - if va != VMerge::None || vb != VMerge::None { - return Err(CmdError::Failed("erasing a border beside a vertically merged cell isn't supported".into())); + // Both sides merged down the same rows (or plain single cells): they become one cell. + if let Some(rows) = aligned_rows(t, &la, &lb) { + let ci0 = t.cell_at_grid(rows.0, la.g0).unwrap_or(0); + for row in rows.0..=rows.1 { + if let Some(ci) = t.cell_at_grid(row, la.g0) { + t.merge_right(row, ci, ci + 1); + } + } + super::table::sync_cell_widths(t); + return finish(s, story, &tp, rows.0, ci0); } - t.merge_right(r, c, c2); - super::table::sync_cell_widths(t); - return finish(s, story, &tp, r, c); + // Two sides that can't make one rectangle: the border is only hidden. + hide_edge(t, true, la.g1, (la.top.min(lb.top), la.bot.max(lb.bot))); + let ci0 = t.cell_at_grid(la.top, la.g0).unwrap_or(0); + return finish(s, story, &tp, la.top, ci0); } let (ga, gb) = (t.grid_col(r, c), t.grid_col(r2, c2)); if ga != gb || sa != sb { - return Err(CmdError::Failed("the cells above and below the border don't line up, so they can't be merged".into())); + hide_edge(t, false, la.bot + 1, (la.g0.min(lb.g0), la.g1.max(lb.g1))); + let ci0 = t.cell_at_grid(la.top, la.g0).unwrap_or(0); + return finish(s, story, &tp, la.top, ci0); } if vb == VMerge::Continue { return Err(CmdError::Failed("those cells are already merged".into())); @@ -629,4 +725,67 @@ mod tests { assert!(s.run("table.eraser", &json!({"page": 0, "x": 1e30, "y": -1e30})).is_err()); assert_eq!(first_table(&s), start); } + + /// A 3x3 table with columns 1 and 2 merged down rows 0-1 (made with the eraser itself). + fn merged_columns_table() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.table", &json!({"rows": 3, "cols": 3})).unwrap(); + for cell in [2, 1] { + let a = cell_rect(&mut s, 0, cell); + s.run("table.eraser", &json!({"page": 0, "x": a.x + a.w / 2.0, "y": a.bottom() + 0.5})).unwrap(); + } + s + } + + /// The border between two cells merged down the same rows merges them into one cell that is + /// still merged down those rows; one undo step. + #[test] + fn erasing_between_cells_merged_down_the_same_rows_merges_them() { + let mut s = merged_columns_table(); + let start = first_table(&s); + assert_eq!(shape(&start), json!([[1, 1, 1], [1, 1, 1], [1, 1, 1]])); + let a = cell_rect(&mut s, 0, 1); + s.run("table.eraser", &json!({"page": 0, "x": a.right() + 0.5, "y": a.y + 3.0})).unwrap(); + let t = first_table(&s); + assert_eq!(shape(&t), json!([[1, 2], [1, 2], [1, 1, 1]])); + assert_eq!((t.rows[0].cells[1].props.vmerge, t.rows[1].cells[1].props.vmerge), (VMerge::Restart, VMerge::Continue)); + let merged = cell_rect(&mut s, 0, 1); + assert!(merged.w > a.w * 1.9 && (merged.h - a.h).abs() < 0.5, "{merged:?}"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(first_table(&s), start); + } + + /// A border whose two sides can't make one rectangle (a one-row cell beside a cell merged + /// down two rows) is hidden on both sides instead of refused; nothing merges; one undo step. + #[test] + fn erasing_between_cells_that_dont_line_up_hides_the_border() { + let mut s = merged_columns_table(); + let start = first_table(&s); + let a = cell_rect(&mut s, 0, 0); + s.run("table.eraser", &json!({"page": 0, "x": a.right() + 0.5, "y": a.y + a.h / 2.0})).unwrap(); + let t = first_table(&s); + assert_eq!(shape(&t), shape(&start), "no merge"); + for r in 0..2 { + let right = t.rows[r].cells[0].props.borders.and_then(|b| b.right); + let left = t.rows[r].cells[1].props.borders.and_then(|b| b.left); + assert!(right.is_some_and(|b| !b.is_visible()) && left.is_some_and(|b| !b.is_visible()), "row {r}"); + } + assert!(t.rows[2].cells[0].props.borders.is_none(), "rows outside the edge are untouched"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(first_table(&s), start); + // A cell spanning two columns above single cells: the horizontal border is hidden too. + let a = cell_rect(&mut s, 0, 1); + s.run("table.eraser", &json!({"page": 0, "x": a.right() + 0.5, "y": a.y + 3.0})).unwrap(); + let merged = cell_rect(&mut s, 0, 1); + let before = first_table(&s); + s.run("table.eraser", &json!({"page": 0, "x": merged.x + 3.0, "y": merged.bottom() + 0.5})).unwrap(); + let t = first_table(&s); + assert_eq!(shape(&t), shape(&before), "no merge"); + let vis = |r: usize, c: usize, top: bool| { + let b = t.rows[r].cells[c].props.borders; + b.and_then(|b| if top { b.top } else { b.bottom }).is_some_and(|b| !b.is_visible()) + }; + assert!(vis(1, 1, false) && vis(2, 1, true) && vis(2, 2, true), "both sides along the whole edge"); + assert!(!vis(2, 0, true), "the first column is not part of the edge"); + } } From 2fd3b556f36f867296a8a51f44b06aae49855759 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Thu, 15 Oct 2026 01:48:32 +0600 Subject: [PATCH 113/199] Layout: keep with next moves the heading when the next paragraph moves for widow control (#966) (#1049) --- crates/layout/src/lib.rs | 25 +++++-- crates/layout/src/tests.rs | 129 +++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index bb2671b4c..5e950e58a 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3333,10 +3333,12 @@ fn space_after_kept(ctx: &Ctx, block: usize, rp: &wordcraft_doc::resolve::Resolv after } -/// Height of the first line of the block after `block` (for keep-with-next). -/// The room the paragraphs after `block` need on its page for keep with next: the next one's -/// first line, or all of it and what it keeps with in turn when it is kept with the next too -/// (a chain moves together in Word). +/// The room the paragraphs after `block` need on the page for keep with next: enough of the +/// next one to start there — its first line, two of its lines with widow/orphan control (it +/// doesn't leave a single line at the bottom of a page; it moves over whole instead), or all +/// of it under keep lines together — or all of it and what it keeps with in turn when it is +/// kept with the next too (a chain moves together in Word). Word carries a kept heading over +/// when the paragraph after it cannot start on the page (#966). fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32 { match ctx.doc.body.get(block + 1).map(|b| &**b) { Some(Block::Para(p)) => { @@ -3364,7 +3366,20 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32 if pl.rp.keep_next && depth < 8 { pl.rp.space_before + pl.height + pl.rp.space_after + next_first_line(ctx, block + 1, width, depth + 1) } else { - pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0) + // The room this one needs to start on the page with the kept paragraph before + // it (#966). + let to = |k: usize| match (pl.lines.first(), pl.lines.get(k)) { + (Some(first), Some(last)) => last.top + last.height - first.top, + _ => 0.0, + }; + pl.rp.space_before + + if pl.rp.keep_lines { + pl.height + } else if pl.rp.widow_control && pl.lines.len() >= 2 { + to(1) + } else { + to(0) + } } } Some(Block::Table(_)) => 24.0, diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 9a9ad40fa..aaf7b0c71 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5038,6 +5038,135 @@ fn keep_with_next_moves_a_paragraph_only_when_it_would_fit() { assert_eq!(pages(fit - 1, kn.clone(), kn.clone()), [1, 1, 1, 1, 1, 1], "a kept chain moves together"); } +/// A kept heading goes with the paragraph after it when that paragraph cannot start on its +/// page (#966): with widow/orphan control on, one line of it at the bottom of the page moves +/// over with the rest, so only two lines below the heading hold it there. Word and LibreOffice +/// both move the heading; WordCraft left it alone. With the control off the single line stays, +/// and the heading keeps it company. +#[test] +fn keep_with_next_carries_the_heading_when_widow_control_moves_the_next_paragraph() { + // Page of the heading, then of the next paragraph's lines at "Bb", "Bc" and "Be". + let pages = |fillers: usize, widow: bool| -> [usize; 4] { + let mut d = Document::new(); + let plain = ParaProps { + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)), + widow_control: Some(widow), + ..Default::default() + }; + let para = |text: &str, p: &ParaProps| { + let mut x = wordcraft_doc::Paragraph::with_text(text, Default::default()); + x.props = p.clone(); + wordcraft_doc::para_block(x) + }; + d.body = (0..fillers).map(|i| para(&format!("F{i}"), &plain)).collect(); + let mut heading = wordcraft_doc::Paragraph::with_text("HEADING", Default::default()); + heading.props = ParaProps { keep_next: Some(true), ..plain.clone() }; + d.body.push(wordcraft_doc::para_block(heading)); + d.body.push(para("Bb\nBc\nBd\nBe\nBf", &plain)); + let l = lay(&d); + [ + l.caret(&Pos::body(fillers, 0)).unwrap().page, + l.caret(&Pos::body(fillers + 1, 0)).unwrap().page, + l.caret(&Pos::body(fillers + 1, 3)).unwrap().page, + l.caret(&Pos::body(fillers + 1, 9)).unwrap().page, + ] + }; + // Find the page edge that leaves the heading room for exactly one line of the paragraph + // after it: with the control off it starts there and the rest goes over. + let fit = (20..100).find(|n| pages(*n, false) == [0, 0, 1, 1]).expect("a filler count that leaves room for one line after the heading"); + assert_eq!(pages(fit, false), [0, 0, 1, 1], "without the control one line stays: {fit}"); + // With widow/orphan control the paragraph moves over whole, and the heading goes with it. + assert_eq!(pages(fit, true), [1, 1, 1, 1], "the heading follows the paragraph: {fit}"); + // One line more of room holds both of its first lines below the heading, under either + // setting. + assert_eq!(pages(fit - 1, true), [0, 0, 0, 1], "two lines below the heading hold it"); + assert_eq!(pages(fit - 1, false), [0, 0, 0, 1]); +} + +/// The chain case of #966: a kept heading before a kept paragraph before a long one all move +/// over when the last one cannot start on the page, and all stay when it can. +#[test] +fn keep_with_next_chain_carries_over_when_the_last_paragraph_cannot_start() { + let pages = |fillers: usize, widow: bool| -> Vec { + let mut d = Document::new(); + let plain = ParaProps { + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)), + widow_control: Some(widow), + ..Default::default() + }; + let para = |text: &str, p: &ParaProps| { + let mut x = wordcraft_doc::Paragraph::with_text(text, Default::default()); + x.props = p.clone(); + wordcraft_doc::para_block(x) + }; + d.body = (0..fillers).map(|i| para(&format!("F{i}"), &plain)).collect(); + let kept = ParaProps { keep_next: Some(true), ..plain.clone() }; + d.body.push(para("HEADING", &kept)); + d.body.push(para("MID", &kept)); + d.body.push(para("Bb\nBc\nBd\nBe\nBf", &plain)); + let l = lay(&d); + // Pages of the heading, the kept middle, and the long paragraph's first two lines. + vec![ + l.caret(&Pos::body(fillers, 0)).unwrap().page, + l.caret(&Pos::body(fillers + 1, 0)).unwrap().page, + l.caret(&Pos::body(fillers + 2, 0)).unwrap().page, + l.caret(&Pos::body(fillers + 2, 3)).unwrap().page, + ] + }; + // The page edge that leaves the heading and the kept middle room for one line of the long + // paragraph after them. + let fit = (20..100).find(|n| pages(*n, false) == [0, 0, 0, 1]).expect("a filler count that leaves room for one line after the chain"); + // With widow/orphan control the long paragraph cannot start there, so the whole chain + // moves over. + assert_eq!(pages(fit, true), [1, 1, 1, 1], "the chain follows the paragraph: {fit}"); + assert_eq!(pages(fit, false), [0, 0, 0, 1], "without the control one line stays: {fit}"); +} + +/// Keep lines together on the paragraph after a kept heading: when the whole paragraph does +/// not fit below the heading, both move over. Word does not leave a kept heading holding part +/// of a paragraph that keep lines together will not split (#966). +#[test] +fn keep_with_next_carries_the_heading_when_keep_lines_moves_the_next_paragraph() { + let pages = |fillers: usize, kept_lines: bool| -> Vec { + let mut d = Document::new(); + let plain = ParaProps { + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)), + widow_control: Some(false), + ..Default::default() + }; + let para = |text: &str, p: &ParaProps| { + let mut x = wordcraft_doc::Paragraph::with_text(text, Default::default()); + x.props = p.clone(); + wordcraft_doc::para_block(x) + }; + d.body = (0..fillers).map(|i| para(&format!("F{i}"), &plain)).collect(); + let mut heading = wordcraft_doc::Paragraph::with_text("HEADING", Default::default()); + heading.props = ParaProps { keep_next: Some(true), ..plain.clone() }; + d.body.push(wordcraft_doc::para_block(heading)); + let body = ParaProps { keep_lines: Some(kept_lines), ..plain.clone() }; + d.body.push(para("Bb\nBc\nBd", &body)); + let l = lay(&d); + // Pages of the heading and of the paragraph's first two lines. + vec![ + l.caret(&Pos::body(fillers, 0)).unwrap().page, + l.caret(&Pos::body(fillers + 1, 0)).unwrap().page, + l.caret(&Pos::body(fillers + 1, 3)).unwrap().page, + ] + }; + // The page edge that leaves the heading room for exactly one line of the three-line + // paragraph after it. + let fit = (20..100).find(|n| pages(*n, false) == [0, 0, 1]).expect("a filler count that leaves room for one line after the heading"); + // With keep lines together the paragraph moves over whole, and the heading goes with it. + assert_eq!(pages(fit, true), [1, 1, 1], "the heading follows the paragraph: {fit}"); + assert_eq!(pages(fit, false), [0, 0, 1], "without it the paragraph splits: {fit}"); +} + /// An empty paragraph that only carries a section break before a continuous section takes no /// room, measured in Word for Windows (`plan/word` R21, 10 cases): the text after it follows right /// on, and columns balanced above it don't count it (two one-line paragraphs in two columns sit From 060a3333b8e2b11237b754fccd78bad32b153bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergiu=20T=C3=A2rpescu?= Date: Wed, 14 Oct 2026 22:48:44 +0300 Subject: [PATCH 114/199] Ribbon menu buttons carry their name for accessibility (#1050) The Breaks, Margins, Orientation, Size, Columns and other drop-down buttons are custom-painted and never called widget_info, so assistive tech and automation saw them as unlabelled. Register the tip as the button's name, as the small buttons already do. Regression test: the Layout > Breaks menu is reachable and its Page and Next Page items insert a page break and a section break. --- crates/ui-egui/src/widgets.rs | 1 + crates/ui-egui/tests/breaks_menu.rs | 60 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 crates/ui-egui/tests/breaks_menu.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3c916dffa..590ac3161 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -298,6 +298,7 @@ pub fn menu_button( resp }; let resp = resp.on_hover_text(tip); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip)); if !on { return; } diff --git a/crates/ui-egui/tests/breaks_menu.rs b/crates/ui-egui/tests/breaks_menu.rs new file mode 100644 index 000000000..db8550279 --- /dev/null +++ b/crates/ui-egui/tests/breaks_menu.rs @@ -0,0 +1,60 @@ +//! The ribbon's Breaks menu: its controls carry a name, and each item inserts the break it says. + +use serde_json::json; +use wordcraft_engine::Session; +use wordcraft_ui_egui::{Services, WordApp}; + +/// A WordCraft window on the Layout tab, ready for ribbon clicks. +fn layout_tab() -> egui_kittest::Harness<'static, WordApp> { + let app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("one\ntwo")), Services::default()); + 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); + }, + app, + ); + for _ in 0..6 { + h.step(); + } + h.state_mut().run("ui.tab", json!({"tab": "Layout"})).unwrap(); + for _ in 0..3 { + h.step(); + } + h +} + +/// Open the ribbon's Breaks menu and click the item labelled `item`. +fn click_break(h: &mut egui_kittest::Harness<'static, WordApp>, item: &str) { + use egui_kittest::kittest::{NodeT, Queryable}; + h.get_by_role_and_label(egui::accesskit::Role::Button, "Breaks").click(); + for _ in 0..3 { + h.step(); + } + h.query_all(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button)) + .find(|n| n.accesskit_node().label().as_deref().map(str::trim) == Some(item)) + .unwrap_or_else(|| panic!("the {item} item is in the Breaks menu")) + .click(); + for _ in 0..3 { + h.step(); + } +} + +#[test] +fn page_break_from_the_menu_adds_a_page() { + let mut h = layout_tab(); + let before = h.state_mut().session.layout().pages.len(); + click_break(&mut h, "Page"); + let after = h.state_mut().session.layout().pages.len(); + assert!(after > before, "the menu Page break added a page (was {before}, now {after})"); +} + +#[test] +fn section_break_from_the_menu_splits_sections() { + let mut h = layout_tab(); + let before = h.state_mut().session.doc.sections().len(); + click_break(&mut h, "Next Page"); + let after = h.state_mut().session.doc.sections().len(); + assert!(after > before, "the menu section break added a section (was {before}, now {after})"); +} From e29fbdb0bf1bd6335a5c912f2a7b46e5061a6d0c Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 22:03:32 +0200 Subject: [PATCH 115/199] Page background: picture and texture fills are drawn and kept on save (#999) (#1036) * Page background: picture and texture fills are drawn and kept on save (#999) Co-Authored-By: Claude Sonnet 5.5 * rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 15 +++++++++ crates/docx/src/read/mod.rs | 15 ++++++++- crates/docx/src/write/mod.rs | 26 ++++++++++++++-- crates/docx/tests/roundtrip.rs | 21 +++++++++++++ crates/engine/src/cmd/design.rs | 1 + crates/pdf/src/lib.rs | 33 ++++++++++++++++++++ crates/render/src/lib.rs | 54 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/printing.rs | 4 +++ docs/file-format-parity.md | 1 + 11 files changed, 169 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b97e41c37..9f28bb38a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 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 | | 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. | | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3132ad6d6..bdadbed17 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -433,6 +433,8 @@ pub struct Settings { /// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit. pub consecutive_hyphen_limit: u32, pub page_color: Option, + /// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour. + pub page_picture: Option, pub watermark: Option, /// Theme fonts (major = headings, minor = body). pub major_font: String, @@ -525,6 +527,18 @@ pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0; /// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say. pub const DEFAULT_GRID: f32 = 9.0; +/// A picture fill for the page background (`v:background/v:fill` with an image). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PagePicture { + /// Key into [`Document::media`]. + pub media: String, + /// `v:fill/@type` (`tile`, `frame`, `tile`...); tiled when `tile`, stretched to the page otherwise. + pub fill_type: String, + /// `o:title` of the fill (texture name), kept for round trip. + pub title: String, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Watermark { @@ -569,6 +583,7 @@ impl Default for Settings { hyphenate_caps: true, consecutive_hyphen_limit: 0, page_color: None, + page_picture: None, watermark: None, major_font: styles::HEADING_FONT.into(), minor_font: styles::BODY_FONT.into(), diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index f996a95f0..6f6d71be7 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -22,7 +22,9 @@ use wordcraft_doc::para::OBJ; use wordcraft_doc::props::Rgb; use wordcraft_doc::section::NumFormat; use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts}; -use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block}; +use wordcraft_doc::{ + Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block, +}; use crate::DocxError; use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt}; @@ -228,6 +230,17 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) { r.doc.settings.page_color = Some(bg); } + // A picture or texture fill: `w:background/v:background/v:fill` with a relationship id. + if let Some(fill) = root.child("w:background").and_then(|b| b.child("v:background")).and_then(|b| b.child("v:fill")) { + let rid = fill.attr("r:id").or_else(|| fill.attr("r:embed")).or_else(|| fill.attr("o:relid")); + if let Some(media) = rid.and_then(|id| r.media_for(&rels, id)) { + r.doc.settings.page_picture = Some(PagePicture { + media, + fill_type: fill.attr("type").unwrap_or("").chars().take(32).collect(), + title: fill.attr("o:title").unwrap_or("").chars().take(255).collect(), + }); + } + } if let Some(body) = root.child("w:body") { let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index cd0c6e312..013baf624 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -189,8 +189,28 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); w.open("w:document", &ns_refs); - if let Some(c) = doc.settings.page_color { - w.empty("w:background", &[("w:color", &c.hex())]); + let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media)); + if pic.is_some() || doc.settings.page_color.is_some() { + let color = doc.settings.page_color.map(|c| c.hex()); + let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect(); + w.open("w:background", &attrs); + if let Some(p) = pic + && let Some(file) = wr.media_files.get(&p.media) + { + let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); + wr.used_media.insert(p.media.clone()); + w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]); + let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)]; + if !p.fill_type.is_empty() { + fa.push(("type", &p.fill_type)); + } + if !p.title.is_empty() { + fa.push(("o:title", &p.title)); + } + w.empty("v:fill", &fa); + w.close("v:background"); + } + w.close("w:background"); } w.open("w:body", &[]); wr.blocks(&mut w, &doc.body, &mut rels, true, 0); @@ -912,7 +932,7 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O if s.remove_date_time { w.empty("w:removeDateAndTime", &[]); } - if s.page_color.is_some() { + if s.page_color.is_some() || s.page_picture.is_some() { w.empty("w:displayBackgroundShape", &[]); } if s.mirror_margins { diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index cfcacb6c3..11d583098 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -2239,3 +2239,24 @@ fn replace_part(zip_bytes: &[u8], name: &str, data: &[u8]) -> Vec { } out.finish().unwrap().into_inner() } + +/// A texture or picture page background (`v:background/v:fill`) survives a save (#999): the image +/// part, its relationship and the fill attributes. +#[test] +fn page_background_picture_round_trips() { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 150, 100, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let mut d = Document::new(); + d.settings.page_color = Some(Rgb(0xFF, 0xFF, 0xCC)); + d.media.insert("paper.png".into(), Arc::new(png.clone())); + d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "paper.png".into(), fill_type: "tile".into(), title: "Paper".into() }); + let r = rt(&d); + let pic = r.settings.page_picture.as_ref().expect("picture kept"); + assert_eq!((pic.fill_type.as_str(), pic.title.as_str()), ("tile", "Paper")); + assert_eq!(r.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice())); + assert_eq!(r.settings.page_color, d.settings.page_color); + // Saved again, it is still there. + assert!(rt(&r).settings.page_picture.is_some()); +} diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 7d96fe856..d1fedc160 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -71,6 +71,7 @@ pub fn specs() -> Vec { .params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#), CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| { s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse); + s.doc.settings.page_picture = None; sel_result(s) }) .params(r#"{"color": "RRGGBB" | null}"#), diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 00e9c392d..1cb097fc3 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -755,6 +755,9 @@ impl Exporter<'_> { s.set_fill(None); }); } + if let Some(pic) = self.doc.settings.page_picture.clone() { + self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.page_picture(s, &pic, w, h)); + } if let Some(wm) = self.doc.settings.watermark.clone() && !wm.text.trim().is_empty() { @@ -1132,6 +1135,36 @@ impl Exporter<'_> { s.set_stroke(None); } + /// The page's picture or texture fill: tiled at 96 dpi for a `tile` fill (when that is a sane + /// number of tiles), else stretched over the page. + fn page_picture(&mut self, s: &mut Surface, pic: &wordcraft_doc::PagePicture, w: f32, h: f32) { + const MAX_TILES: f32 = 4096.0; + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return; + } + let dims = self + .doc + .media + .get(&pic.media) + .and_then(|b| image::ImageReader::new(std::io::Cursor::new(b.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()); + if let Some((pw, ph)) = dims.filter(|_| pic.fill_type.eq_ignore_ascii_case("tile")) { + let (tw, th) = (pw as f32 * 0.75, ph as f32 * 0.75); + if tw >= 1.0 && th >= 1.0 && (w / tw).ceil() * (h / th).ceil() <= MAX_TILES { + let mut y = 0.0; + while y < h { + let mut x = 0.0; + while x < w { + self.picture(s, &Rect::new(x, y, tw, th), &pic.media, &[0.0; 4], 1.0); + x += tw; + } + y += th; + } + return; + } + } + self.picture(s, &Rect::new(0.0, 0.0, w, h), &pic.media, &[0.0; 4], 1.0); + } + fn picture(&mut self, s: &mut Surface, rect: &Rect, media: &str, crop: &[f32; 4], alpha: f32) { let Some(clip) = rect_path(rect) else { return }; if let Some(v) = self.vector(media) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 75805b0d9..bd7ef50b7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -331,6 +331,37 @@ fn device_pixel(view: Affine) -> f64 { if scale.is_finite() && scale > 1e-9 { 1.0 / scale } else { 1.0 } } +/// Largest page-background picture decoded, in pixels (hostile files). +const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024; + +/// Draw the page's picture or texture fill (Design › Page Color › Fill Effects) behind the content: +/// tiled at its natural size (96 dpi) for a `tile` fill, stretched over the page otherwise. +fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine) { + let Some(pic) = &doc.settings.page_picture else { return }; + let Some(bytes) = doc.media.get(&pic.media) else { return }; + match image_size(bytes) { + Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => {} + _ => return, + } + let Some(pm) = pixmap_for(doc, &pic.media) else { return }; + let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64); + let (w, h) = (page.w as f64, (page.h as f64).min(1e6)); + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return; + } + let tile = pic.fill_type.eq_ignore_ascii_case("tile"); + let (sx, sy) = if tile { (0.75, 0.75) } else { (w / pw, h / ph) }; + let extend = if tile { peniko::Extend::Repeat } else { peniko::Extend::Pad }; + ctx.set_transform(view); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium).with_extend(extend), + }); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w, h)); + ctx.reset_paint_transform(); +} + fn pixmap_for(doc: &Document, key: &str) -> Option> { let bytes = doc.media.get(key)?; if let Some(v) = lock(images()).get(key, bytes) { @@ -458,6 +489,7 @@ pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, ctx.set_transform(Affine::IDENTITY); ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0)); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)); + draw_page_picture(&mut ctx, doc, page, view); let items = page_display(doc, page, &opts.display); let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0); if let Some(wm) = &doc.settings.watermark { @@ -1257,6 +1289,28 @@ mod tests { assert!(!RenderOptions::default().text_darkening); } + #[test] + fn page_picture_is_painted_behind_the_text() { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 200, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let mut d = Document::new(); + d.media.insert("bg.png".into(), Arc::new(png)); + for kind in ["tile", "frame"] { + d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: kind.into(), title: String::new() }); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); + let i = ((300 * img.width + 300) * 4) as usize; + assert_eq!(&img.pixels[i..i + 3], &[10, 200, 30], "{kind}"); + } + // An undecodable picture leaves the paper alone. + d.media.insert("bg.png".into(), Arc::new(b"junk".to_vec())); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); + assert_eq!(&img.pixels[0..3], &[255, 255, 255]); + } + #[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 fe8dab748..9888f66eb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -567,7 +567,8 @@ fn view_key(app: &WordApp, dim_body: bool) -> u64 { (v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h); app.session.prefs.markup.hash(&mut h); dim_body.hash(&mut h); - format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h); + format!("{:?}{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.page_picture, app.session.doc.settings.watermark) + .hash(&mut h); app.session.doc.media.len().hash(&mut h); h.finish() } diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index 059f34f52..5fe61f5b6 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -142,10 +142,14 @@ pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result wordcraft_engine::print::MAX_PRINT_PAGES) { return Err("the selected pages and copies exceed the print-job page limit".into()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba5d774b4..8660b6467 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -20,6 +20,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` 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. Details below | +| `.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` 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 | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | From 85fcbabcb16ab34cef692057654b85f7ee09ced8 Mon Sep 17 00:00:00 2001 From: gossardla Date: Wed, 14 Oct 2026 22:03:52 +0200 Subject: [PATCH 116/199] Ribbon: holding a press no longer scrolls the document (#1019) (#1041) * test(engine): drop a needless drop() that clippy 1.97 rejects (drop_non_drop) * Ribbon: a held press no longer scrolls the document (#1019) - Double-click release paths in the canvas returned before ending the text drag - The stale drag made any later press drive the edge auto-scroll - A drag now ends once the button is up, wherever it was released - Test: header double-click, then a held press on the ribbon --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/ui-egui/src/canvas.rs | 8 +++++- crates/ui-egui/src/lib.rs | 54 ++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f28bb38a..eb8d358ca 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-14 · **Change:** trivial (title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; 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-14 · **Change:** trivial (progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; 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,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 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 | +| 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released | | 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. | | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | @@ -332,6 +333,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) | | 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9888f66eb..ec820514f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -874,7 +874,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }; // Dragging a selection near the viewport's edge scrolls on (#688): from a crawl at the margin // to full speed at the edge, through the same offset the wheel writes. - let (down, pointer, dt) = ui.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.stable_dt)); + let (down, released, pointer, dt) = ui.input(|i| (i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.latest_pos(), i.stable_dt)); + // A release `mouse` returned before seeing (double-click paths, off the canvas) ends the drag here (#1019). + if app.canvas.dragging && !down && !released { + app.canvas.dragging = false; + app.canvas.column_drag = None; + app.canvas.unit_drag = None; + } let auto_delta = if !app.session.view.read_mode && app.canvas.drag_selecting() && down diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af91b3f4e..8682ada1f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3153,6 +3153,60 @@ mod tests { assert!(!a.session.sel.is_collapsed(), "the drag left a selection"); } + /// #1019: a double-click in the header area returned before the release ended the drag, so + /// a later press held on the ribbon edge-scrolled the document up. + #[test] + fn a_press_on_the_ribbon_after_a_header_double_click_does_not_scroll() { + let ctx = egui::Context::default(); + let mut a = app(); + a.run("insert.editHeader", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Header"})).unwrap(); + a.run("insert.closeHeader", json!({})).unwrap(); + a.run("text.insert", json!({"text": "lorem ipsum dolor sit amet consectetur ".repeat(1200)})).unwrap(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp, events: Vec| { + t += 1.0 / 60.0; + let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + let btn = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for _ in 0..30 { + frame(&mut a, Vec::new()); + } + a.run("caret.docStart", json!({})).unwrap(); + for _ in 0..30 { + frame(&mut a, Vec::new()); + } + let (Some(vp), Some(page)) = (a.canvas.viewport, a.canvas.page_rects.first().copied()) else { panic!("the canvas laid out") }; + let header = egui::pos2(page.left() + 150.0, page.top() + 70.0); + assert!(vp.contains(header)); + frame(&mut a, vec![egui::Event::PointerMoved(header)]); + for _ in 0..2 { + frame(&mut a, vec![btn(header, true)]); + frame(&mut a, vec![btn(header, false)]); + } + frame(&mut a, Vec::new()); + assert!(!a.canvas.drag_selecting(), "the double-click's release ended the drag"); + a.run("insert.closeHeader", json!({})).unwrap(); + a.run("caret.docEnd", json!({})).unwrap(); + for _ in 0..30 { + frame(&mut a, Vec::new()); + } + let ribbon = egui::pos2(vp.center().x, vp.min.y - 40.0); + let before = a.canvas.scroll_offset; + frame(&mut a, vec![egui::Event::PointerMoved(ribbon), btn(ribbon, true)]); + for _ in 0..30 { + frame(&mut a, vec![egui::Event::PointerMoved(ribbon)]); + } + assert_eq!(a.canvas.scroll_offset, before, "holding a press on the ribbon leaves the document where it is"); + frame(&mut a, vec![btn(ribbon, false)]); + } + /// The control channel's `ui.wheel` scrolls the canvas like a mouse wheel over it. #[test] fn control_wheel_scrolls_the_canvas() { From 1f01a13dfc4ff207a2276162340c1e314e952bab Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 22:04:13 +0200 Subject: [PATCH 117/199] Track Changes: deletions inside a field result are tracked, accepted and saved (#1037) (#1043) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/docx/src/read/story.rs | 6 ++- crates/engine/src/tests.rs | 69 +++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index eb8d358ca..cb3826b1e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm |---|---| | 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 | | 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released | +| 2026-10-12 | Tracked insertions and deletions inside a field result (a `=SUM(ABOVE)` subtotal) are kept as revisions: Accept All and Reject All act on them and a save writes the `w:del` back (#1037) | | 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. | | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 1c28338a5..8d26a498d 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -815,7 +815,11 @@ impl Reader<'_> { // A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded // picture) can't be a text result: keep the field as a range around it so the picture // stays in the document and is saved again. The linked source is never read. - if !nested_in_instr && f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some())) { + // A tracked insertion or deletion inside the result (one the field itself isn't wrapped + // in) keeps the field a range too: a cached text result has one revision at most, so + // each piece stays an ordinary run with its own `w:ins` / `w:del` (#1037). + let tracked_inside = f.buf.iter().any(|(_, p)| (p.ins, p.del) != (f.props.ins, f.props.del)); + if !nested_in_instr && (tracked_inside || f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some()))) { self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props); for (it, p) in f.buf { match it { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 19b269cf1..328a4a349 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5601,6 +5601,75 @@ fn insert_picture_takes_a_windows_metafile() { let _ = std::fs::remove_dir_all(&dir); } +/// A DOCX built in code: a tracked deletion inside a field result, as Word writes when a +/// formula field's result is edited (#1037). The first field's result is split across the field +/// end; the second has its whole result deleted. +fn tracked_field_result_docx() -> Vec { + use std::io::Write; + const WHO: &str = r#"w:author="Tester" w:date="2026-10-12T00:00:00Z""#; + let field = |instr: &str, inside: &str, after: &str| { + format!( + r#" {instr} {inside}{after}"# + ) + }; + let del = |id: u32, t: &str| format!(r#"{t}"#); + let ins = |id: u32, t: &str| format!(r#"{t}"#); + let p1 = field("=SUM(ABOVE)", &del(1, "$"), &(del(2, "150.00") + &ins(3, "$140.00"))); + let p2 = field("=2+3", &del(4, "5"), &ins(5, "five")); + let xml = format!( + r#"{p1}{p2}"# + ); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, value) in [ + ("word/document.xml", xml.as_str()), + ( + "_rels/.rels", + r#""#, + ), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(value.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +#[test] +fn tracked_deletion_inside_a_field_result_is_a_revision_and_is_saved() { + let doc = wordcraft_docx::read(&tracked_field_result_docx()).unwrap(); + // Revisions with one author and date are shared: one deletion, one insertion. + let kinds: Vec<_> = doc.revisions.iter().map(|r| r.kind).collect(); + assert_eq!(kinds, [wordcraft_doc::RevisionKind::Delete, wordcraft_doc::RevisionKind::Insert]); + // The deleted `$` is still in the text, marked deleted (not ordinary result text). + let p = doc.body[0].as_para().unwrap(); + assert_eq!(p.text.replace(wordcraft_doc::para::OBJ, ""), "$150.00$140.00"); + // Saving keeps the deletion inside the result, with its text as delText. + let bytes = wordcraft_docx::write(&doc).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + let sep = xml.find(r#"w:fldCharType="separate""#).unwrap(); + let end = xml.find(r#"w:fldCharType="end""#).unwrap(); + let inside = &xml[sep..end]; + assert!(inside.contains(" Vec { + body_texts(s).iter().map(|t| t.replace(wordcraft_doc::para::OBJ, "")).collect() +} + +#[test] +fn accept_and_reject_all_handle_a_tracked_deletion_inside_a_field_result() { + let open = || Session::new(wordcraft_docx::read(&tracked_field_result_docx()).unwrap()); + let mut s = open(); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(visible_texts(&s), ["$140.00", "five"]); + assert!(s.doc.revisions.is_empty()); + let mut s = open(); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(visible_texts(&s), ["$150.00", "5"]); +} + #[test] fn rename_document_preserves_saved_contents_and_rejects_collisions() { let dir = std::env::temp_dir().join(format!("wordcraft-rename-{}", std::process::id())); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8660b6467..c809e6b4d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -19,8 +19,8 @@ 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` 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. Details below | | `.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` 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 | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | From f9f8b67c54fcf183f7effa594cbb1b390eba6a34 Mon Sep 17 00:00:00 2001 From: Guru Swarupa <112751363+guruswarupa@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:34:34 +0530 Subject: [PATCH 118/199] feat(ui): implement Ctrl+drag to copy objects on canvas (#1046) * feat(ui): implement Ctrl+drag to copy objects on canvas * feat(ui): implement Ctrl+drag to copy objects on canvas and update translations for "Copy to" * vi: Copy to Co-Authored-By: Claude Opus 5.5 * rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/engine/src/cmd/objects.rs | 45 ++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 60 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr-latn.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/objects.rs | 33 ++++++++++++++-- docs/ui-parity.md | 5 ++- 24 files changed, 160 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cb3826b1e..a90a67ab4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -236,6 +236,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| | 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) | +| 2026-10-12 | Ctrl+drag copies an object (#1014): holding Ctrl (⌘ on macOS) while dragging a picture, shape or text box leaves the original and drops a copy where you let go (text boxes get their own text); the copy is selected, the clipboard is untouched, one undo step; `arrange.copyTo` | | 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler | | 2026-10-11 | Tables on the rulers (#700): with the caret in a table the horizontal ruler marks its row's column borders and the vertical ruler its rows' bottoms; dragging a marker resizes the column (the columns right of it move along; with Shift the neighbour makes up the difference) or the row, one undo step, Escape cancels; in a cell the indents and tabs are measured from the cell | | 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) | @@ -335,6 +336,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) | +| 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) | | 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 9694fc7c0..42451e4fc 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -334,6 +334,9 @@ pub fn specs() -> Vec { CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds) .params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object floats it)"#) .when(has_movable), + CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to) + .params(r#"{"x": pt, "y": pt, "page"?: n} (Ctrl+drag: a copy of the selected object lands with its top-left there and is selected; the original and the clipboard stay as they were)"#) + .when(has_movable), CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| { let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET); let (dx, dy) = (d("dx"), d("dy")); @@ -699,6 +702,24 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)})) } +/// Ctrl+drag: copy the selected object (a text box gets its own story) and move the copy to +/// (x, y), leaving the original in place and the clipboard alone. The copy is selected. +fn copy_to(s: &mut Session, v: &Value) -> CmdResult { + let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?; + if p::f32(v, "x").is_none() || p::f32(v, "y").is_none() { + return Err(CmdError::Params("`x` and `y` are required".into())); + } + let len = wordcraft_doc::para::OBJ.len_utf8(); + let end = Pos { off: pos.off + len, ..pos.clone() }; + let frag = s.doc.copy_range(&pos, &end); + s.sel = Selection::caret(end.clone()); + super::paste::insert(s, frag)?; + s.touch(); // lay out the copy before moving it + // The copy sits right after the original; select it and move it like a drag would. + s.sel = Selection { anchor: end.clone(), focus: Pos { off: end.off + len, ..end } }; + bounds(s, v) +} + /// Put the object at `pos` with its top-left at (x, y) on `page` (default: where it is), as a /// drag does in Word: it floats (an inline object gets Square wrapping) and anchors to the /// paragraph under its top edge, positioned relative to that paragraph's column and top. So the @@ -1469,6 +1490,30 @@ mod tests { assert_eq!((object_area(&mut s).rect.x, object_area(&mut s).rect.y), (250.0, 300.0), "{insert}"); } + /// Ctrl+drag: a copy lands where dropped and is selected; the original stays, the clipboard + /// is untouched, and it is one undo step. + #[test] + fn copy_to_drops_a_copy_and_leaves_the_original() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "Some text "})).unwrap(); + s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap(); + s.run("arrange.bounds", &json!({"width": 100, "height": 60, "page": 0, "x": 100, "y": 200})).unwrap(); + s.clipboard_text = "keep".into(); + let count = |s: &Session| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().objects.len(); + assert_eq!(count(&s), 1); + s.run("arrange.copyTo", &json!({"page": 0, "x": 300, "y": 200})).unwrap(); + assert_eq!(count(&s), 2); + assert_eq!(s.clipboard_text, "keep"); + let (_, sel) = selected(&s).unwrap(); + assert!(matches!(sel, InlineObject::Shape { .. })); + let at = s.layout().find_object(0, |o| (o.rect.x - 300.0).abs() < 0.5).expect("the copy is at the drop point"); + assert_eq!(at.pos(), selected(&s).unwrap().0); + assert!(s.layout().find_object(0, |o| (o.rect.x - 100.0).abs() < 0.5).is_some(), "the original stays"); + assert!(s.run("arrange.copyTo", &json!({"page": 0})).is_err()); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(count(&s), 1); + } + #[test] fn shapes_can_be_clicked_resized_and_dragged() { for kind in ["rectangle", "ellipse", "star", "arrow"] { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ec820514f..e41b1837c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2978,6 +2978,66 @@ mod tests { assert_eq!((s, e), (Pos::body(0, 0), Pos::body(0, 6))); } + /// #1014: Ctrl+drag on a shape leaves it and drops a copy; a plain drag moves it. + #[test] + fn ctrl_drag_copies_an_object() { + use egui::{Event, Modifiers, PointerButton}; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Some text"})).unwrap(); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + a.run("insert.shape", json!({"kind": "rectangle"})).unwrap(); + a.run("arrange.bounds", json!({"width": 100, "height": 60, "page": 0, "x": 200, "y": 300})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let objects = |h: &mut egui_kittest::Harness<'_, WordApp>| { + let doc = &h.state().session.doc; + doc.para(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).map_or(0, |p| p.objects.len()) + }; + let xs = |h: &mut egui_kittest::Harness<'_, WordApp>| { + let mut v: Vec = Vec::new(); + for x in (0..600).step_by(50) { + if h.state_mut().session.layout().find_object(0, |o| (o.rect.x - x as f32).abs() < 0.5).is_some() { + v.push(x); + } + } + v + }; + let drag = |h: &mut egui_kittest::Harness<'_, WordApp>, mods: Modifiers| { + let from = page_to_screen(h.state_mut(), 0, 250.0, 330.0).expect("on screen"); + let to = page_to_screen(h.state_mut(), 0, 400.0, 330.0).expect("on screen"); + h.event(Event::ModifiersChanged(mods)); + h.event(Event::PointerMoved(from)); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: mods }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: mods }); + for _ in 0..3 { + h.step(); + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + }; + assert_eq!((objects(&mut h), xs(&mut h)), (1, vec![200])); + drag(&mut h, Modifiers::COMMAND); + assert_eq!((objects(&mut h), xs(&mut h)), (2, vec![200, 350]), "the original stays, a copy lands 150pt right"); + // Without Ctrl the object under the pointer (the original) is moved, not copied again: + // it lands on the copy. + drag(&mut h, Modifiers::NONE); + assert_eq!((objects(&mut h), xs(&mut h)), (2, vec![350])); + } + /// #856: the left margin beside body text is Word's selection bar. A click selects the line, /// a double-click the paragraph, a triple-click (or Ctrl/⌘+click) the document; dragging /// selects whole lines and Shift+click extends by lines. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2928a8060..f69f15126 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2462,6 +2462,7 @@ Table Properties… Свойства на таблица… Search Търсене Title Bar Заглавна лента + Copy to Копиране в # Document naming and account controls. Rename Document Преименуване на документ diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 91832ea59..c674cf156 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2466,6 +2466,7 @@ preview Title Ukázka Automatic ({region}) Automaticky ({region}) Search Hledat Title Bar Záhlaví okna + Copy to Kopírovat do # Document naming and account controls. Rename Document Přejmenovat dokument diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 961a5bf3a..16aa52473 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2081,6 +2081,7 @@ Automatic ({region}) Automatisch ({region}) Search Suchen Title Bar Titelleiste + Copy to Kopieren nach # Document naming and account controls. Rename Document Dokument umbenennen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ecb0b04fa..ff8c55bb5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2448,6 +2448,7 @@ preview Title Título Start-up Inicio de la aplicación Show the Start screen when WordCraft starts Mostrar la pantalla de inicio al abrir WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Activado: WordCraft se abre en Archivo › Inicio, con un documento en blanco, las plantillas y tus documentos recientes. Desactivado: abre un documento en blanco. + Copy to Copiar en # Document naming and account controls. Rename Document Cambiar nombre del documento diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index ec479f1e5..b62cfe6a5 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2116,6 +2116,7 @@ Start-up Käivitamine Show the Start screen when WordCraft starts Näita WordCrafti käivitamisel avakuva On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Sees: WordCraft avaneb vaates Fail › Avaleht, kus on tühi dokument, mallid ja hiljutised dokumendid. Väljas: avaneb tühi dokument. + Copy to Kopeeri asukohta # Document naming and account controls. Rename Document Nimeta dokument ümber diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f80080aad..c7ee4e543 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2090,6 +2090,7 @@ Table Properties… Propriétés du tableau… Search Rechercher Title Bar Barre de titre + Copy to Copier vers # Document naming and account controls. Rename Document Renommer le document diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 03ac09ce4..17406e917 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2470,6 +2470,7 @@ Insert Caption… Felirat beszúrása… Table Properties… Táblázat tulajdonságai… Search Keresés + Copy to Másolás ide # Document naming and account controls. Rename Document Dokumentum átnevezése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f66b05af2..a53c7ebef 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2457,6 +2457,7 @@ preview Title Esempio Automatic ({region}) Automatico ({region}) Search Cerca Title Bar Barra del titolo + Copy to Copia in # Document naming and account controls. Rename Document Rinomina documento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 53c559721..378a05b79 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2082,6 +2082,7 @@ Start-up 起動 Show the Start screen when WordCraft starts WordCraft の起動時にスタート画面を表示する On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. オン: WordCraft は [ファイル] › [ホーム] で開き、白紙の文書、テンプレート、最近使った文書を表示します。オフ: 白紙の文書を開きます。 + Copy to コピー先 # Document naming and account controls. Rename Document ドキュメント名の変更 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 915299dfa..2fb5ec15d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2466,6 +2466,7 @@ preview Title Tytuł Typing Pisanie Search Wyszukaj Title Bar Pasek tytułu + Copy to Kopiuj do # Document naming and account controls. Rename Document Zmień nazwę dokumentu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c85fe6017..59f2905f2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2094,6 +2094,7 @@ Start-up Inicialização Show the Start screen when WordCraft starts Mostrar a tela inicial ao abrir o WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Ativado: o WordCraft abre em Arquivo › Início, com um documento em branco, os modelos e seus documentos recentes. Desativado: abre um documento em branco. + Copy to Copiar para # Document naming and account controls. Rename Document Renomear documento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d17aba7ab..85c888ff4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2472,6 +2472,7 @@ preview Title Пример Level 7 Уровень 7 Level 8 Уровень 8 Level 9 Уровень 9 + Copy to Копировать в # Document naming and account controls. Rename Document Переименовать документ diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 75ed6ca10..38177fe9f 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2092,6 +2092,7 @@ Start-up Pokretanje Show the Start screen when WordCraft starts Prikaži početni ekran pri pokretanju programa WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Uključeno: WordCraft se otvara na Fajl › Početak, sa praznim dokumentom, šablonima i nedavnim dokumentima. Isključeno: otvara prazan dokument. + Copy to Kopiraj u # Document naming and account controls. Rename Document Preimenuj dokument diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c75032ad3..e11e2867b 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2096,6 +2096,7 @@ Start-up Покретање Show the Start screen when WordCraft starts Прикажи почетни екран при покретању програма WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Укључено: WordCraft се отвара на Фајл › Почетак, са празним документом, шаблонима и недавним документима. Искључено: отвара празан документ. + Copy to Копирај у # Document naming and account controls. Rename Document Преименуј документ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c42ec2a55..c2206d50c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2471,6 +2471,7 @@ preview Title Örnek Level 7 Düzey 7 Level 8 Düzey 8 Level 9 Düzey 9 + Copy to Şuraya kopyala # Document naming and account controls. Rename Document Belgeyi yeniden adlandır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 084548283..adde1b827 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2094,6 +2094,7 @@ Start-up Запуск Show the Start screen when WordCraft starts Показувати початковий екран під час запуску WordCraft On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Увімкнено: WordCraft відкривається на сторінці Файл › Основне з порожнім документом, шаблонами та нещодавніми документами. Вимкнено: відкривається порожній документ. + Copy to Копіювати до # Document naming and account controls. Rename Document Перейменувати документ diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2151cb7fe..3ce84722c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2545,3 +2545,6 @@ Document name Tên tài liệu Rename Đổi tên Rename Document Đổi tên tài liệu + +# Ctrl+drag copies (#1046) + Copy to Sao chép tới diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3ba6f3e3e..39ce3c257 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2094,6 +2094,7 @@ Start-up 启动 Show the Start screen when WordCraft starts 启动 WordCraft 时显示开始屏幕 On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 开:WordCraft 打开时显示“文件 › 开始”,其中有空白文档、模板和最近使用的文档。关:直接打开空白文档。 + Copy to 复制到 # Document naming and account controls. Rename Document 重命名文档 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 893048caf..50026c454 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2083,6 +2083,7 @@ Start-up 啟動 Show the Start screen when WordCraft starts 啟動 WordCraft 時顯示開始畫面 On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 開:WordCraft 開啟時顯示「檔案 › 常用」,其中有空白文件、範本和最近使用的文件。關:直接開啟空白文件。 + Copy to 複製到 # Document naming and account controls. Rename Document 重新命名文件 diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index aa6c9e840..5c848e5e0 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -44,6 +44,8 @@ pub struct ObjectDrag { picture: bool, /// Dragging a connector's end (false: its start, true: its end) to this page point. end: Option<(bool, (f32, f32))>, + /// Ctrl/⌘ is held: dropping leaves the original and puts a copy at the new place. + copy: bool, /// The alignment guides a move snapped to, shown while dragging ([`crate::guides`]). guides: Vec, /// The object drawn alone over its rotated bounds, for the preview: made once it moves. @@ -165,6 +167,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] } if ui.input(|i| i.pointer.primary_down()) { let shift = ui.input(|i| i.modifiers.shift); + d.copy = ui.input(|i| i.modifiers.command) && d.drag.grab == Grab::Move; if d.drag.grab == Grab::Rotate { d.drag.turn_to(at, pages, scale, shift); } else { @@ -198,14 +201,24 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] // A selected connector's end: drag it to glue it. if pressed && let Some((object, which)) = connector_end_at(app, layout, pages, scale, at) { let drag = Drag::new(Grab::Move, object.page, object.rect, 0.0, at); - app.canvas.obj_drag = - Some(ObjectDrag { drag, object, min: 0.0, picture: false, end: Some((which, (0.0, 0.0))), guides: Vec::new(), preview: None }); + app.canvas.obj_drag = Some(ObjectDrag { + drag, + object, + min: 0.0, + picture: false, + end: Some((which, (0.0, 0.0))), + guides: Vec::new(), + preview: None, + copy: false, + }); return true; } let Some((object, grab)) = grab_at(app, layout, pages, scale, at) else { return false }; // Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group. let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command); - if pressed && adding && grab == Grab::Move && selected(app).is_some() { + // Ctrl on the object that is already selected starts a copy-drag instead. + let again = selected(app).is_some_and(|(p, _)| p == object.pos()); + if pressed && adding && grab == Grab::Move && selected(app).is_some() && !(again && !ui.input(|i| i.modifiers.shift)) { let _ = app.run("select.addObject", json!({"pos": object.pos()})); return true; } @@ -219,6 +232,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] }; // Objects in table cells can be selected and resized, not moved; charts neither. if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) { + let copy = grab == Grab::Move && ui.input(|i| i.modifiers.command); app.canvas.obj_drag = Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at), object, @@ -227,6 +241,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] end: None, guides: Vec::new(), preview: None, + copy, }); } } @@ -376,6 +391,10 @@ fn drop(app: &mut WordApp, d: &ObjectDrag) { let _ = app.run("arrange.rotation", json!({"degrees": d.drag.deg})); return; } + Grab::Move if d.copy => { + let _ = app.run("arrange.copyTo", json!({"page": d.drag.to_page, "x": r.x, "y": r.y})); + return; + } Grab::Move => json!({"page": d.drag.to_page, "x": r.x, "y": r.y}), // An inline object stays in the text; a floating one keeps its far edges put. Grab::Resize(_) if d.object.floating() && movable(app, &d.object) => { @@ -487,7 +506,13 @@ fn paint_end_drag(app: &WordApp, d: &ObjectDrag, painter: &Painter, t: &Tokens, /// The pointer over an object or a handle (or dragging): its cursor. pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option { if let Some(d) = &app.canvas.obj_drag { - return Some(if d.end.is_some() { egui::CursorIcon::Crosshair } else { d.drag.grab.cursor() }); + return Some(if d.end.is_some() { + egui::CursorIcon::Crosshair + } else if d.copy && d.drag.moved { + egui::CursorIcon::Copy + } else { + d.drag.grab.cursor() + }); } if connector_end_at(app, layout, pages, scale, at).is_some() { return Some(egui::CursorIcon::Crosshair); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 29639f689..cb1fb4205 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-14 · **Change:** trivial (Cross-reference dialog, #916; previously trivial: title rename and account settings; previously trivial: web Save Changes prompt waits for the save picker, #1315; previously trivial: AutoFit in a nested table, whole cells shaded for a selection through a table, #971; 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-14 · **Change:** trivial (Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: Cross-reference dialog, #916; previously trivial: title rename and account settings; previously trivial: web Save Changes prompt waits for the save picker, #1315; previously trivial: AutoFit in a nested table, whole cells shaded for a selection through a table, #971; 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 @@ -25,7 +25,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 85% | 1–2 | -| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — | +| On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014) | ✅ | 75% | — | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Crop handles missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 50% | 2–3 | | Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border | ✅ | 95% | 1 | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | @@ -51,6 +51,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | On-canvas objects: Ctrl+drag copies a picture, shape or text box (`arrange.copyTo`, #1014) | | 2026-10-12 | trivial | Cross-reference dialog: reference type (heading, bookmark, equation, figure, table), what to insert and the item; the ribbon button opened nothing before (#916); modal dialog count 55 | | 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | From b53fc8ca6efe1d0d5bdc110065782263db108849 Mon Sep 17 00:00:00 2001 From: Otto Leisner <42967854+H0ruu1@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:04:47 +0300 Subject: [PATCH 119/199] fonts: Add platform-specific Hebrew font support (#1052) * fonts: Add platform-specific Hebrew font support Add proper Hebrew font substitution and fallback for Windows, macOS, and Linux: - Windows: Prioritize David/Miriam (Office) and Segoe UI fonts - macOS: Prioritize Helvetica and Geeza Pro (system defaults) - Linux: Use Noto Sans Hebrew and open-source fonts Fixes Hebrew character display issue (showing rectangles) when editing text in fonts without Hebrew glyphs by ensuring the system falls back to Hebrew-capable fonts. Includes platform-optimized font lists in both document font resolution (word.rs) and RTL text fallback search (fontdb.rs). Co-Authored-By: Claude Haiku 4.5 * rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Haiku 4.5 Co-authored-by: Brandon Thomas --- crates/fonts/src/fontdb.rs | 27 +++++++++++++++++++++++++-- crates/fonts/src/word.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 816ac2799..910e9694b 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -459,6 +459,11 @@ fn read_fallback_file(path: &std::path::Path) -> Option> { /// Families preferred for right-to-left scripts (Persian, Arabic, Urdu, Hebrew) when the /// document's font lacks them: open fonts with full Persian coverage first (the Persian-only /// letters پ چ ژ گ, the Persian digits), then the platforms' own. +/// Right-to-left script fonts (Hebrew, Arabic, Persian, Syriac…) in fallback order. +/// Windows prioritizes David/Miriam (Office) and Segoe UI (standard). +/// macOS prioritizes Helvetica, Geeza Pro (system), and SF fonts. +/// Linux uses Noto fonts. +#[cfg(windows)] pub const RTL_FALLBACKS: &[&str] = &[ "Vazirmatn", "Noto Naskh Arabic", @@ -468,14 +473,32 @@ pub const RTL_FALLBACKS: &[&str] = &[ "Shabnam", "Samim", "Noto Sans Hebrew", + "David", + "Miriam", "Segoe UI", "Tahoma", - "Geeza Pro", - "SF Arabic", "Arial", "DejaVu Sans", ]; +#[cfg(target_os = "macos")] +pub const RTL_FALLBACKS: &[&str] = &[ + "Helvetica", + "Vazirmatn", + "Noto Naskh Arabic", + "Noto Sans Arabic", + "Noto Sans Hebrew", + "Geeza Pro", + "SF Arabic", + "Arial", + "Tahoma", + "DejaVu Sans", +]; + +#[cfg(not(any(windows, target_os = "macos")))] +pub const RTL_FALLBACKS: &[&str] = + &["Noto Naskh Arabic", "Noto Sans Arabic", "Noto Sans Hebrew", "Vazirmatn", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans"]; + /// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. #[cfg(not(target_arch = "wasm32"))] const SYSTEM_FALLBACKS: &[&str] = &[ diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index b3c596550..2b1335912 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -38,6 +38,16 @@ fn substitutes(family: &str) -> &'static [&'static str] { // headings and screens. Open fonts with full Persian coverage first. const NASKH: &[&str] = &["Noto Naskh Arabic", "XB Zar", "XB Niloofar", "Amiri", "Vazirmatn", "Noto Sans Arabic", "DejaVu Sans", "Source Serif 4"]; const ARABIC_SANS: &[&str] = &["Vazirmatn", "Sahel", "Shabnam", "Samim", "Noto Sans Arabic", "Noto Naskh Arabic", "DejaVu Sans", "Source Sans 3"]; + // Hebrew document fonts: platform-specific fonts first, then common open source fallbacks. + // Windows: Microsoft Office fonts (David, Miriam) are common; Segoe UI is standard. + // macOS: Helvetica has good Hebrew support; Geeza Pro is included by default. + // Linux: Noto Sans Hebrew is the best option. + #[cfg(windows)] + const HEBREW: &[&str] = &["Noto Sans Hebrew", "David", "Miriam", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans", "Source Sans 3"]; + #[cfg(target_os = "macos")] + const HEBREW: &[&str] = &["Helvetica", "Noto Sans Hebrew", "Arial", "Tahoma", "Geeza Pro", "DejaVu Sans", "Source Sans 3"]; + #[cfg(not(any(windows, target_os = "macos")))] + const HEBREW: &[&str] = &["Noto Sans Hebrew", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans", "Source Sans 3"]; // Palatino Linotype / Book Antiqua: macOS ships the same design as "Palatino"; open // look-alikes with matching metrics (TeX Gyre Pagella, URW P052 / Palladio) on Linux. const PALATINO: &[&str] = &[ @@ -57,6 +67,9 @@ fn substitutes(family: &str) -> &'static [&'static str] { "Source Serif 4", ]; let f = family.to_ascii_lowercase(); + if is_hebrew(&f) { + return HEBREW; + } if is_persian_naskh(&f) { return NASKH; } @@ -181,6 +194,12 @@ fn is_persian_sans(f: &str) -> bool { NAMES.contains(&f) || f.starts_with("iransans") || f.starts_with("iranyekan") } +/// Hebrew document fonts. +fn is_hebrew(f: &str) -> bool { + const NAMES: &[&str] = &["david", "miriam", "noto sans hebrew", "arial hebrew", "calibri hebrew"]; + NAMES.contains(&f) || f.contains("hebrew") +} + fn style_name(bold: bool, italic: bool) -> &'static str { match (bold, italic) { (false, false) => "Regular", @@ -331,4 +350,14 @@ mod tests { let r = resolve("JetBrains Mono", true, true); assert!(r.synth_bold && r.synth_italic); } + + #[test] + fn hebrew_fonts_substitute_hebrew_faces() { + for f in ["David", "Miriam", "Arial Hebrew", "Calibri Hebrew", "Noto Sans Hebrew"] { + assert!(substitutes(f).contains(&"Noto Sans Hebrew"), "{f}"); + assert!(substitutes(f).contains(&"David") || f == "David", "{f}"); + } + // Generic names with "Hebrew" in them also get Hebrew substitutions + assert!(substitutes("SomeFont Hebrew").contains(&"Noto Sans Hebrew")); + } } From d2ff87856ffcef18c49e2c83f25315249a3d0418 Mon Sep 17 00:00:00 2001 From: przemek-arch Date: Wed, 14 Oct 2026 22:05:06 +0200 Subject: [PATCH 120/199] DOCX: merge settings.xml into the original instead of regenerating it (#1034) (#1059) A save regenerated word/settings.xml from the model, so every setting the model doesn't hold was dropped: compatibility options (lost in 18 of 18 real-world Word files), rsids, document variables, proofing state and the colour scheme mapping. The reader now keeps the original part when it holds such settings, and the writer merges into it: the original root and every unowned child stay byte for byte; owned children follow the model in CT_Settings order; in w:compat only compatibilityMode is replaced. The privacy options are the model's since #1062, so they are owned too. Kept children that point at a relationship (a mail-merge data source) are dropped. Anything unexpected falls back to the generated part. Files WordCraft wrote itself keep nothing extra. Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/docx/src/package.rs | 3 + crates/docx/src/read/mod.rs | 3 + crates/docx/src/write/mod.rs | 10 +- crates/docx/src/write/settings_merge.rs | 347 ++++++++++++++++++++++++ crates/docx/tests/settings_merge.rs | 135 +++++++++ docs/file-format-parity.md | 16 +- 6 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 crates/docx/src/write/settings_merge.rs create mode 100644 crates/docx/tests/settings_merge.rs diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 9899118a9..82043a7be 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -63,6 +63,9 @@ pub const FONT_TABLE_PART: &str = "word/fontTable.xml"; /// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`. /// Their opaque bytes and related parts use the same bounded manifest as embedded objects. pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots"; +/// The original `word/settings.xml` when it holds settings the model doesn't (merged into on +/// save by `write::settings_merge`). Not a package part: WordCraft's own bookkeeping. +pub const SETTINGS_ORIG: &str = "wordcraft:settings.orig"; /// Most parts we carry along for a file's embedded objects. pub const MAX_EMBEDDED_PARTS: usize = 4096; /// Most relationships we keep for one such part. diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 6f6d71be7..646e353c8 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -994,6 +994,9 @@ impl Reader<'_> { fn read_settings(&mut self, path: &str) -> Result<(), DocxError> { let Some(root) = self.xml(path)? else { return Ok(()) }; + if let Some(b) = self.pkg.get(path).filter(|b| crate::write::settings_has_unowned(b)) { + self.doc.passthrough.insert(crate::package::SETTINGS_ORIG.into(), Arc::new(b.to_vec())); + } let rels = self.pkg.rels(path); let s = &mut self.doc.settings; for k in root.els() { diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 013baf624..ccd0ff16c 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -1,6 +1,8 @@ //! [`Document`] → DOCX. mod embed; +mod settings_merge; +pub(crate) use settings_merge::has_unowned as settings_has_unowned; mod math; mod props; mod sdt; @@ -376,7 +378,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result settings_merge::merge(orig, &generated).unwrap_or(generated), + None => generated, + } + }, &format!("{CT_WML}settings+xml"), settings_rels, ); diff --git a/crates/docx/src/write/settings_merge.rs b/crates/docx/src/write/settings_merge.rs new file mode 100644 index 000000000..84dfa25ec --- /dev/null +++ b/crates/docx/src/write/settings_merge.rs @@ -0,0 +1,347 @@ +//! `word/settings.xml` merged into the file's original settings part. +//! +//! The model owns only some settings (`super::settings_xml`). Regenerating the part from the +//! model alone drops everything else Word stores there: compatibility options, rsids, document +//! variables, proofing state, the colour scheme mapping, "remove personal information on save"… +//! So when a file brought settings the model doesn't hold ([`has_unowned`]; the reader keeps its +//! bytes under `SETTINGS_ORIG`), the writer merges instead ([`merge`]): the original root start +//! tag (namespace declarations, `mc:Ignorable`) and every child the model doesn't own are kept +//! byte for byte; owned children come from the generated part (or go, when the model turned +//! them off); owned children the original lacks are inserted at their CT_Settings position; in +//! `w:compat` only the `compatibilityMode` setting is replaced. Kept children that refer to a +//! relationship (`r:id`, e.g. a mail merge data source) are dropped: their relationships aren't +//! carried. Anything unexpected returns `None`, and the generated part is written as before. + +/// Children of `w:settings` the model writes (`super::settings_xml`). +const OWNED: &[&str] = &[ + "w:removePersonalInformation", + "w:removeDateAndTime", + "w:displayBackgroundShape", + "w:mirrorMargins", + "w:attachedTemplate", + "w:linkStyles", + "w:trackRevisions", + "w:documentProtection", + "w:defaultTabStop", + "w:autoHyphenation", + "w:consecutiveHyphenLimit", + "w:hyphenationZone", + "w:doNotHyphenateCaps", + "w:evenAndOddHeaders", + "w:drawingGridHorizontalSpacing", + "w:drawingGridVerticalSpacing", + "w:characterSpacingControl", + "w:footnotePr", + "w:endnotePr", + "m:mathPr", +]; + +/// Owned children whose original, when there is one, is kept as is. +const KEEP_ORIGINAL: &[&str] = &["m:mathPr", "w:displayBackgroundShape"]; + +/// CT_Settings' sequence (ECMA-376 §17.15.1.78), for placing inserted children. +const ORDER: &[&str] = &[ + "w:writeProtection", + "w:view", + "w:zoom", + "w:removePersonalInformation", + "w:removeDateAndTime", + "w:doNotDisplayPageBoundaries", + "w:displayBackgroundShape", + "w:printPostScriptOverText", + "w:printFractionalCharacterWidth", + "w:printFormsData", + "w:embedTrueTypeFonts", + "w:embedSystemFonts", + "w:saveSubsetFonts", + "w:saveFormsData", + "w:mirrorMargins", + "w:alignBordersAndEdges", + "w:bordersDoNotSurroundHeader", + "w:bordersDoNotSurroundFooter", + "w:gutterAtTop", + "w:hideSpellingErrors", + "w:hideGrammaticalErrors", + "w:activeWritingStyle", + "w:proofState", + "w:formsDesign", + "w:attachedTemplate", + "w:linkStyles", + "w:stylePaneFormatFilter", + "w:stylePaneSortMethod", + "w:documentType", + "w:mailMerge", + "w:revisionView", + "w:trackRevisions", + "w:doNotTrackMoves", + "w:doNotTrackFormatting", + "w:documentProtection", + "w:autoFormatOverride", + "w:styleLockTheme", + "w:styleLockQFSet", + "w:defaultTabStop", + "w:autoHyphenation", + "w:consecutiveHyphenLimit", + "w:hyphenationZone", + "w:doNotHyphenateCaps", + "w:showEnvelope", + "w:summaryLength", + "w:clickAndTypeStyle", + "w:defaultTableStyle", + "w:evenAndOddHeaders", + "w:bookFoldRevPrinting", + "w:bookFoldPrinting", + "w:bookFoldPrintingSheets", + "w:drawingGridHorizontalSpacing", + "w:drawingGridVerticalSpacing", + "w:displayHorizontalDrawingGridEvery", + "w:displayVerticalDrawingGridEvery", + "w:doNotUseMarginsForDrawingGridOrigin", + "w:drawingGridHorizontalOrigin", + "w:drawingGridVerticalOrigin", + "w:doNotShadeFormData", + "w:noPunctuationKerning", + "w:characterSpacingControl", + "w:printTwoOnOne", + "w:strictFirstAndLastChars", + "w:noLineBreaksAfter", + "w:noLineBreaksBefore", + "w:savePreviewPicture", + "w:doNotValidateAgainstSchema", + "w:saveInvalidXml", + "w:ignoreMixedContent", + "w:alwaysShowPlaceholderText", + "w:doNotDemarcateInvalidXml", + "w:saveXmlDataOnly", + "w:useXSLTWhenSaving", + "w:saveThroughXslt", + "w:showXMLTags", + "w:alwaysMergeEmptyNamespace", + "w:updateFields", + "w:hdrShapeDefaults", + "w:footnotePr", + "w:endnotePr", + "w:compat", + "w:docVars", + "w:rsids", + "m:mathPr", + "w:attachedSchema", + "w:themeFontLang", + "w:clrSchemeMapping", + "w:doNotIncludeSubdocsInStats", + "w:doNotAutoCompressPictures", + "w:forceUpgrade", + "w:captions", + "w:readModeInkLockDown", + "w:smartTagType", + "sl:schemaLibrary", + "w:shapeDefaults", + "w:doNotEmbedSmartTags", + "w:decimalSymbol", + "w:listSeparator", +]; + +fn rank(name: &str) -> usize { + ORDER.iter().position(|n| *n == name).unwrap_or(ORDER.len()) +} + +/// A child element of the root: its qualified name and byte span. +struct Child { + name: String, + start: usize, + end: usize, +} + +/// The root start tag's span end and the root's direct children, or `None` for anything the +/// scanner doesn't expect (CDATA, DOCTYPE, a different root). +fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec)> { + let open = xml.find(&format!("<{root}"))?; + let open_end = open + xml.get(open..)?.find('>')? + 1; + if xml.get(open_end - 2..open_end)? == "/>" { + return None; + } + let close = xml.rfind(&format!(""))?; + let mut out = Vec::new(); + let mut depth = 0usize; + let mut i = open_end; + let mut cur: Option<(String, usize)> = None; + while i < close { + let rest = xml.get(i..close)?; + let Some(lt) = rest.find('<') else { break }; + let at = i + lt; + let tail = xml.get(at..close)?; + if tail.starts_with("")? + 3; + continue; + } + if tail.starts_with("")? + 2; + continue; + } + let gt = at + tail.find('>')? + 1; + let tag = xml.get(at..gt)?; + if tag.starts_with("' && *c != '/').collect(); + let selfclose = tag.ends_with("/>"); + if depth == 0 { + if selfclose { + out.push(Child { name, start: at, end: gt }); + } else { + cur = Some((name, at)); + depth = 1; + } + } else if !selfclose { + depth += 1; + } + } + i = gt; + } + if depth != 0 { + return None; + } + Some((open_end, close, out)) +} + +/// The original `w:compat` with its `compatibilityMode` taken from the generated one. +fn merge_compat(orig: &str, generated: &str) -> Option { + let mode = |s: &str| -> Option<(usize, usize)> { + let at = s.find("w:name=\"compatibilityMode\"")?; + let tag_start = s.get(..at)?.rfind('<')?; + let tag_end = tag_start + s.get(tag_start..)?.find('>')? + 1; + Some((tag_start, tag_end)) + }; + match (mode(orig), mode(generated)) { + (Some((a, b)), Some((c, d))) => Some(format!("{}{}{}", orig.get(..a)?, generated.get(c..d)?, orig.get(b..)?)), + (None, Some((c, d))) => { + // No mode in the original: add the generated one before ``. + let close = orig.rfind("")?; + Some(format!("{}{}{}", orig.get(..close)?, generated.get(c..d)?, orig.get(close..)?)) + } + _ => Some(orig.to_string()), + } +} + +/// Does `original` (a settings part) hold anything the model doesn't write? Then the reader keeps +/// it for [`merge`]. False for parts WordCraft wrote itself, and for anything it can't scan. +pub fn has_unowned(original: &[u8]) -> bool { + let Some(xml) = std::str::from_utf8(original).ok() else { return false }; + let Some((_, _, kids)) = children(xml, "w:settings") else { return false }; + kids.iter().any(|k| match k.name.as_str() { + "w:zoom" => false, + // Anything in `w:compat` beyond its own tag and the compatibility mode. + "w:compat" => xml.get(k.start..k.end).is_some_and(|c| { + let mode = usize::from(c.contains("w:name=\"compatibilityMode\"")); + c.matches(" 0 + }), + n => !OWNED.contains(&n), + }) +} + +/// The merged settings part, or `None` to fall back to `generated`. +pub fn merge(original: &[u8], generated: &[u8]) -> Option> { + let orig = std::str::from_utf8(original).ok()?; + let gen_s = std::str::from_utf8(generated).ok()?; + let (o_open, o_close, o_kids) = children(orig, "w:settings")?; + let (_, _, g_kids) = children(gen_s, "w:settings")?; + // Prefixes the generated children use must be declared by the original root. + let root_tag = orig.get(..o_open)?; + for p in ["w", "m", "r"] { + if g_kids.iter().any(|k| k.name.starts_with(&format!("{p}:"))) && !root_tag.contains(&format!("xmlns:{p}=")) { + return None; + } + } + let g_text = |k: &Child| gen_s.get(k.start..k.end).map(str::to_string); + let mut used = vec![false; g_kids.len()]; + let mark = |name: &str, used: &mut Vec| -> Option { + let gi = g_kids.iter().enumerate().position(|(i, g)| g.name == name && !used.get(i).copied().unwrap_or(true))?; + if let Some(u) = used.get_mut(gi) { + *u = true; + } + Some(gi) + }; + let mut out: Vec<(String, String)> = Vec::new(); + for k in &o_kids { + let text = orig.get(k.start..k.end)?; + if KEEP_ORIGINAL.contains(&k.name.as_str()) { + // The model reads these only partly (math settings) or writes them only in one case + // (the background shape flag, with a page colour): the original wins. + mark(&k.name, &mut used); + out.push((k.name.clone(), text.to_string())); + } else if OWNED.contains(&k.name.as_str()) { + if let Some(gi) = mark(&k.name, &mut used) { + out.push((k.name.clone(), g_text(g_kids.get(gi)?)?)); + } + } else if k.name == "w:compat" { + let merged = match mark("w:compat", &mut used) { + Some(gi) => merge_compat(text, &g_text(g_kids.get(gi)?)?)?, + None => text.to_string(), + }; + out.push((k.name.clone(), merged)); + } else if !text.contains("r:id=") { + out.push((k.name.clone(), text.to_string())); + } + } + for (gi, g) in g_kids.iter().enumerate() { + let owned = OWNED.contains(&g.name.as_str()) || g.name == "w:compat"; + if used.get(gi).copied().unwrap_or(true) || !owned { + continue; + } + let r = rank(&g.name); + let at = out.iter().position(|(n, _)| rank(n) > r).unwrap_or(out.len()); + out.insert(at, (g.name.clone(), g_text(g)?)); + } + let mut s = String::with_capacity(orig.len() + 256); + s.push_str(root_tag); + for (_, t) in &out { + s.push_str(t); + } + s.push_str(orig.get(o_close..)?); + Some(s.into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GEN: &str = r#""#; + + #[test] + fn odd_or_hostile_originals_fall_back_or_merge_without_panicking() { + let odd = [ + "", + "", + "", + r#""#, + r#""#, + r#""#, + "\u{1F600}", + r#""#, + ]; + for o in odd { + let _ = merge(o.as_bytes(), GEN.as_bytes()); + let _ = has_unowned(o.as_bytes()); + } + assert!(merge(&[0xff, 0xfe], GEN.as_bytes()).is_none()); + // Missing declarations for a prefix the generated part uses: fall back. + assert!(merge(br#""#, GEN.as_bytes()).is_none()); + } + + #[test] + fn a_compat_block_without_a_mode_gets_the_generated_one() { + let orig = r#""#; + let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap(); + assert!(out.contains("") && out.contains("compatibilityMode"), "{out}"); + assert!(has_unowned(orig.as_bytes())); + assert!(!has_unowned(GEN.as_bytes())); + } +} diff --git a/crates/docx/tests/settings_merge.rs b/crates/docx/tests/settings_merge.rs new file mode 100644 index 000000000..875a4f34c --- /dev/null +++ b/crates/docx/tests/settings_merge.rs @@ -0,0 +1,135 @@ +//! Settings a save used to drop: `word/settings.xml` is merged into the file's original, so +//! settings the model doesn't hold (compatibility options, rsids, document variables, proofing +//! state, "remove personal information"…) survive, and owned ones follow the model. Hand-written +//! fixtures (from the spec), no files produced by Word. + +use std::collections::BTreeMap; +use std::io::{Read, Write}; + +const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +fn zip(entries: &[(&str, Vec)]) -> 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() +} + +fn unzip(bytes: &[u8]) -> BTreeMap> { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = BTreeMap::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let mut b = Vec::new(); + f.read_to_end(&mut b).unwrap(); + assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name()); + } + out +} + +fn text(files: &BTreeMap>, name: &str) -> String { + String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap() +} + +fn rels(list: &[(&str, &str, &str)]) -> Vec { + let mut s = + String::from(r#""#); + for (id, ty, target) in list { + s.push_str(&format!(r#""#)); + } + s.push_str(""); + s.into_bytes() +} + +/// A package: `body`, document relationships, root relationships after the main document's, +/// and extra parts. +fn package(body: &str, doc_rels: &[(&str, &str, &str)], root_rels: &[(&str, &str, &str)], extra: &[(&str, &str)]) -> Vec { + let doc = format!( + r#"{body}"# + ); + let mut root = vec![("rIdMain", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "word/document.xml")]; + root.extend_from_slice(root_rels); + let mut entries: Vec<(&str, Vec)> = + vec![("_rels/.rels", rels(&root)), ("word/document.xml", doc.into_bytes()), ("word/_rels/document.xml.rels", rels(doc_rels))]; + for (p, c) in extra { + entries.push((p, c.as_bytes().to_vec())); + } + zip(&entries) +} + +fn save(doc: &wordcraft_doc::Document) -> BTreeMap> { + unzip(&wordcraft_docx::write(doc).expect("write")) +} + +const SETTINGS: &str = r#" +"#; + +fn with_settings(settings: &str) -> Vec { + package("Text", &[("rId1", &format!("{REL}/settings"), "settings.xml")], &[], &[("word/settings.xml", settings)]) +} + +fn pos(s: &str, needle: &str) -> usize { + s.find(needle).unwrap_or_else(|| panic!("{needle} missing from {s}")) +} + +#[test] +fn unmodelled_settings_survive_a_save_and_owned_ones_follow_the_model() { + let mut doc = wordcraft_docx::read(&with_settings(SETTINGS)).unwrap(); + assert!(doc.settings.track_changes); + doc.settings.track_changes = false; + let files = save(&doc); + let s = text(&files, "word/settings.xml"); + // Kept byte for byte: everything the model doesn't hold, the original root and zoom. + for kept in [ + r#""#, + r#""#, + r#"w:name="overrideTableStyleFontSizeAndJustification""#, + r#"w:name="enableOpenTypeFeatures""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#"mc:Ignorable="w14 w15""#, + ] { + assert!(s.contains(kept), "{kept} lost: {s}"); + } + // Owned: Track Changes was turned off; one compatibility mode; the mail merge source (a + // relationship that isn't carried) is dropped rather than left dangling. + assert!(!s.contains("trackRevisions"), "{s}"); + assert_eq!(s.matches("compatibilityMode").count(), 1, "{s}"); + assert!(!s.contains("mailMerge") && !s.contains("rId9"), "{s}"); + // The privacy options are the model's (#1062): written once each, and gone when turned off. + assert_eq!(s.matches("removePersonalInformation").count(), 1, "{s}"); + assert_eq!(s.matches("removeDateAndTime").count(), 1, "{s}"); + // CT_Settings order holds for kept and owned children. + assert!(pos(&s, "removePersonalInformation") < pos(&s, "defaultTabStop")); + assert!(pos(&s, "defaultTabStop") < pos(&s, "")); + assert!(pos(&s, "") < pos(&s, "")); + // Turning Track Changes back on inserts it at its place. + doc.settings.track_changes = true; + let s = text(&save(&doc), "word/settings.xml"); + assert!(pos(&s, "proofState") < pos(&s, "") && pos(&s, "") < pos(&s, "defaultTabStop"), "{s}"); + let mut private = doc.clone(); + private.settings.remove_personal_info = false; + private.settings.remove_date_time = false; + let p = text(&save(&private), "word/settings.xml"); + assert!(!p.contains("removePersonalInformation") && !p.contains("removeDateAndTime"), "{p}"); + // A second generation is identical. + let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + assert_eq!(text(&save(&again), "word/settings.xml"), s); +} + +#[test] +fn settings_written_by_wordcraft_keep_no_original() { + let doc = wordcraft_doc::Document::new(); + let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + 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")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c809e6b4d..202a57f2c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: ODT tracked changes read and written, gap 19; previously trivial: RTF tracked insertions and deletions read and written, gap 19; 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-14 · **Change:** minor (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: DOCX save keeps unmodelled settings, #1034; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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; @@ -72,7 +72,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. No native stroke rendering, eraser, conversion or replay; ink nested in groups/canvases remains unsupported | | 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | -| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | +| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)) | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) | | Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | @@ -160,6 +160,17 @@ Preservation is separate from field evaluation. Native Word reopening and refresh, full schema validation and company documents remain untested. Synthetic checks do not raise readiness estimates. No Word-derived files are fixtures. +## Settings preservation + +A save used to regenerate `settings.xml` from the model alone, so every setting the model doesn't +hold was dropped without a warning (on 18 real-world Word documents: compatibility settings in 18 +of 18; the privacy options are modelled since #1062). Now the writer merges into the +file's original (`crates/docx/src/write/settings_merge.rs`): the original root and every child +the model doesn't own stay byte for byte; owned children follow the model, in CT_Settings order; +in `w:compat` only `compatibilityMode` is replaced. Kept children that point at a relationship +(a mail-merge data source) are dropped. Files WordCraft wrote itself keep nothing extra. Tests: +`crates/docx/tests/settings_merge.rs`. + ## Revision history | Date | Change | Summary | @@ -179,6 +190,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-13 | trivial | PDF export draws horizontal and vertical lines inside groups (#1474) | | 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) | | 2026-10-14 | trivial | `.doc` tables: cell shading read from every SHD record (`sprmTDefTableShd`/`ShdRaw` and their 2nd/3rd ranges for cells 22-62; Word 97 `Shd80` clear fills now show their background colour; exact colours win over the palette fallback), the row-end mark no longer becomes an extra empty column, and a table no longer inherits the previous table's grid, borders, alignment or indent (#1380). Horizontally merged rows (a row of fewer, wider cells) come with #1123 | +| 2026-10-14 | minor | DOCX save merges `settings.xml` into the file's original: unmodelled settings (compatibility options, rsids, document variables, proofing state…) survive (#1034) | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | From a4870128b5a94533e6d99f1a307de2c5a7f38f61 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 16:11:27 -0400 Subject: [PATCH 121/199] CI: Hebrew font substitution test expects David only on Windows #1052's test asserted that every Hebrew font falls back to David, but only the Windows fallback list has it, so it failed on Linux and macOS. Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/word.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 2b1335912..18daef4a1 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -355,7 +355,10 @@ mod tests { fn hebrew_fonts_substitute_hebrew_faces() { for f in ["David", "Miriam", "Arial Hebrew", "Calibri Hebrew", "Noto Sans Hebrew"] { assert!(substitutes(f).contains(&"Noto Sans Hebrew"), "{f}"); - assert!(substitutes(f).contains(&"David") || f == "David", "{f}"); + // David is a Windows font; only the Windows list offers it. + if cfg!(target_os = "windows") { + assert!(substitutes(f).contains(&"David") || f == "David", "{f}"); + } } // Generic names with "Hebrew" in them also get Hebrew substitutions assert!(substitutes("SomeFont Hebrew").contains(&"Noto Sans Hebrew")); From 896c1bbebfee5e407c0dc58b7ac9a354da42c4d4 Mon Sep 17 00:00:00 2001 From: gossardla Date: Wed, 14 Oct 2026 22:23:31 +0200 Subject: [PATCH 122/199] Comments: a click inside a selected balloon moves the caret in its text (#1013) (#1051) * test(engine): drop a needless drop() that clippy 1.97 rejects (drop_non_drop) * Comments: a click inside a selected balloon moves the caret in its text (#1013) - The canvas under the markup area also "contains" the pointer, so a press on the balloon counted as a click on the page and left it - Presses over a balloon card now belong to the card, in both the deselect check and the document click in mouse() - Test: press, drag and release inside the selected balloon; a click on the page still leaves it --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/ui-egui/src/canvas.rs | 17 +++++++---- crates/ui-egui/src/lib.rs | 57 ++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a90a67ab4..a567fee85 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-14 · **Change:** trivial (progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; 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-14 · **Change:** trivial (progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 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 | | 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released | | 2026-10-12 | Tracked insertions and deletions inside a field result (a `=SUM(ABOVE)` subtotal) are kept as revisions: Accept All and Reject All act on them and a save writes the `w:del` back (#1037) | @@ -335,6 +336,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: clicking in a comment's text moves its caret (#1013) | | 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) | | 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) | | 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e41b1837c..7512a564e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1248,10 +1248,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // the scroll area is built, so it reads last frame's viewport. app.canvas.viewport = Some(out.inner_rect); let (resp, rects) = out.inner; - // A click on the page leaves the selected comment balloon; a click inside a balloon (its - // Delete or Resolve button, say) does not (#1085). - let in_balloon = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| app.canvas.balloon_rects.iter().any(|(_, r)| r.contains(p))); - if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() && !in_balloon { + // A click on the page leaves the selected comment balloon. + if app.canvas.balloon.is_some() + && ui.input(|i| i.pointer.any_pressed()) + && resp.contains_pointer() + && !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| over_balloon(app, p)) + { deselect_balloon(app, ui.ctx()); } // Space and Enter also set `Response::clicked` on a focused click-sensing widget @@ -1710,6 +1712,11 @@ pub fn select_balloon(app: &mut WordApp, ctx: &egui::Context, id: u32) { app.canvas.balloon_h = 0.0; } +/// A press over a balloon card belongs to the card, though the canvas under it also contains the pointer (#1013). +fn over_balloon(app: &WordApp, p: Pos2) -> bool { + app.canvas.balloon_rects.iter().any(|(_, r)| r.contains(p)) +} + /// Leave the selected balloon, writing back what was typed in it (its fields may not be drawn /// again to notice they lost focus). pub fn deselect_balloon(app: &mut WordApp, ctx: &egui::Context) { @@ -1888,7 +1895,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo let _ = app.run("select.paragraph", json!({})); return; } - let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer(); + let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer() && !over_balloon(app, p); if pressed { let now = ui.input(|i| i.time); let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8682ada1f..3f63d2710 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3153,6 +3153,63 @@ mod tests { assert!(!a.session.sel.is_collapsed(), "the drag left a selection"); } + /// #1013: a press inside the selected comment's balloon (to move the caret in its text) left + /// the balloon, because the canvas under it also "contained" the pointer. + #[test] + fn a_press_inside_the_selected_comment_keeps_it_selected() { + let ctx = egui::Context::default(); + let mut a = app(); + a.run("text.insert", json!({"text": "Some text to comment on. ".repeat(40)})).unwrap(); + a.run("select.word", json!({})).unwrap(); + a.run("review.newComment", json!({"text": "A comment with a few words in it"})).unwrap(); + a.session.view.comments_pane = false; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 700.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp, events: Vec| { + t += 1.0 / 60.0; + let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + let btn = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for _ in 0..30 { + frame(&mut a, Vec::new()); + } + let (id, card) = *a.canvas.balloon_rects.first().expect("the comment has a balloon"); + frame(&mut a, vec![egui::Event::PointerMoved(card.center())]); + frame(&mut a, vec![btn(card.center(), true)]); + frame(&mut a, vec![btn(card.center(), false)]); + for _ in 0..5 { + frame(&mut a, Vec::new()); + } + assert_eq!(a.canvas.balloon, Some(id), "a click on the balloon selects it"); + let (_, card) = *a.canvas.balloon_rects.iter().find(|(i, _)| *i == id).unwrap(); + let (focus, scroll) = (a.session.sel.clone(), a.canvas.scroll_offset); + // Press, drag a little and release inside the comment's text. + let (from, to) = (card.left_top() + egui::vec2(30.0, 30.0), card.left_top() + egui::vec2(80.0, 30.0)); + frame(&mut a, vec![egui::Event::PointerMoved(from)]); + frame(&mut a, vec![btn(from, true)]); + assert_eq!(a.canvas.balloon, Some(id), "the press stays in the balloon"); + for _ in 0..3 { + frame(&mut a, vec![egui::Event::PointerMoved(to)]); + } + assert!(!a.canvas.drag_selecting(), "the press didn't start a drag in the document"); + frame(&mut a, vec![btn(to, false)]); + assert_eq!(a.canvas.balloon, Some(id), "still selected after the release"); + assert_eq!(a.session.sel, focus, "the document's caret didn't move"); + assert_eq!(a.canvas.scroll_offset, scroll); + // A press on the page itself still leaves the balloon. + let page = a.canvas.page_rects.first().copied().unwrap(); + let body = page.left_top() + egui::vec2(120.0, 160.0); + frame(&mut a, vec![egui::Event::PointerMoved(body)]); + frame(&mut a, vec![btn(body, true)]); + frame(&mut a, vec![btn(body, false)]); + assert_eq!(a.canvas.balloon, None, "a click on the page leaves the balloon"); + } + /// #1019: a double-click in the header area returned before the release ended the drag, so /// a later press held on the ribbon edge-scrolled the document up. #[test] From a04356933cf1c773b1cbe47c5356e03f488fe25d Mon Sep 17 00:00:00 2001 From: Todd Pederson <34252100+Toddpe@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:23:50 -0400 Subject: [PATCH 123/199] UI: mini toolbar steps aside for the ribbon (#1069) * Clippy 1.96: simplify boolean guards, drop a needless drop() Rust 1.96's clippy flags nonminimal_bool in find_pattern, table_mouse and table_resize, and drop_non_drop in a ribbon test; `cargo xtask ci` failed at clippy on main. Same logic, rewritten as clippy suggests. Co-Authored-By: Claude Opus 5.5 * UI: mini toolbar steps aside for the ribbon The mini toolbar covered the ribbon's drop-downs (the font size list opened under it). A press outside the document and the bar (the ribbon, a pane, the rulers, the status bar) now puts the bar away until the selection changes or the next press in the document; presses on popups (the bar's own lists, an open ribbon list over the page) neither hide nor restore it. The bar also moves from the Tooltip layer to Middle, so every popup and menu draws over it, as its comment always said. Co-Authored-By: Claude Opus 5.5 * UI: mini toolbar scrolls under the ribbon, not over it With text selected, scrolling the selection up toward the ribbon carried the mini toolbar along on top of the rulers and ribbon. The bar is now clipped to the document area: it slides under the rulers and ribbon (or the status bar) with the text and goes once none of it shows. Its area is constrained to the document for hit testing too, so the hidden part no longer swallows clicks and drags meant for the ruler or the ribbon underneath. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/find_pattern.rs | 2 +- crates/ui-egui/src/canvas.rs | 5 + crates/ui-egui/src/mini_toolbar.rs | 218 +++++++++++++++++++++++++- crates/ui-egui/src/table_mouse.rs | 2 +- crates/ui-egui/src/table_resize.rs | 2 +- docs/ui-parity.md | 4 +- 6 files changed, 222 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/find_pattern.rs b/crates/engine/src/cmd/find_pattern.rs index 2c39bca3a..8702fcbec 100644 --- a/crates/engine/src/cmd/find_pattern.rs +++ b/crates/engine/src/cmd/find_pattern.rs @@ -154,7 +154,7 @@ pub fn compile(query: &str, o: &Opts) -> Result { } }; // Word's wildcard search always matches case. - let case_insensitive = !o.match_case && !(o.wildcards && !o.regex); + let case_insensitive = !o.match_case && (!o.wildcards || o.regex); Ok(Compiled { pattern: pat, case_insensitive, spans_paras: spans }) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7512a564e..807541cc9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -90,6 +90,9 @@ pub struct CanvasState { pub context_menu_at: Option, /// Screen rect of the selection's first line, for the floating mini toolbar. pub mini_anchor: Option, + /// The selection the mini toolbar was put away for by a click outside the document (the + /// ribbon, a pane, the status bar); it comes back when the selection changes. + pub mini_dismissed: Option, /// A picture, shape or text box being dragged by its frame. pub(crate) obj_drag: Option, /// An ink stroke being drawn with a pen (Draw tab). @@ -196,6 +199,7 @@ impl Default for CanvasState { context_menu_from_keys: false, context_menu_at: None, mini_anchor: None, + mini_dismissed: None, pasted: false, swallow_text: false, alt_code: String::new(), @@ -1393,6 +1397,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if let (Some(h), Some(v)) = (hruler, vruler) { rulers(app, ui, h, v, &rects, &layout, geo.scale); } + crate::mini_toolbar::track_dismissal(app, ui.ctx(), area); if let Some(sel) = app.canvas.mini_anchor && app.ui.mini_toolbar { diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index 8f3259051..d4c187759 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -131,10 +131,36 @@ pub fn visible(app: &WordApp) -> bool { if app.session.cell_range().is_some() { return false; } + // Nor once a click outside the document put it away for this selection (`track_dismissal`). + if app.canvas.mini_dismissed.as_ref() == Some(&app.session.sel) { + return false; + } // Never for a collapsed caret. !app.session.sel.is_collapsed() } +/// Puts the bar away when the user turns to the ribbon (or a pane, the rulers, the status bar): +/// a press outside the document hides it until the selection changes, so it never covers the +/// ribbon's drop-downs. Runs every frame; `viewport` is the document area. +pub fn track_dismissal(app: &mut WordApp, ctx: &egui::Context, viewport: Rect) { + if app.canvas.mini_dismissed.as_ref().is_some_and(|d| *d != app.session.sel) { + app.canvas.mini_dismissed = None; + } + let Some(p) = ctx.input(|i| if i.pointer.any_pressed() { i.pointer.press_origin() } else { None }) else { return }; + // A press on a popup (the bar's own lists, or a ribbon list already open) neither hides the + // bar nor brings it back: hiding it would close its own list before the click lands. + if ctx.layer_id_at(p).is_some_and(|l| l.order >= Order::Foreground) { + return; + } + app.canvas.mini_dismissed = if viewport.contains(p) { + // A press in the document starts a new selection, which gets the bar again even when it + // covers the same text. + None + } else { + Some(app.session.sel.clone()) + }; +} + /// Show the bar: `selection` is the screen rect of the selection's first line, `viewport` the /// canvas area it must stay inside. pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: Rect) { @@ -142,14 +168,23 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R return; } let Some(bar) = anchor_rect(selection.min.y, selection.min.x, viewport) else { return }; + // The bar belongs to the page: as the selection scrolls under the ribbon (or past the status + // bar) it slides under it with the text, and goes once none of it shows. + let shown = bar.intersect(viewport); + if !shown.is_positive() { + return; + } let t = Tokens::get(ctx); - // The bar sits above the document but below every popup, and is not movable: it is an overlay - // pinned to the selection, so moving it would lose the association. - // Draw the bar pinned above the selection. egui stops a click at the topmost widget, so a - // click on the bar never reaches the document underneath. - let _area = egui::Area::new(egui::Id::new("wc_mini_toolbar")).order(Order::Tooltip).interactable(true).fixed_pos(bar.min); - _area.show(ctx, |ui| { - ui.set_clip_rect(bar); + // The bar sits on the Middle layer: above the document (Background), below every popup and + // menu (Foreground: the ribbon's drop-downs, the context menu, its own lists) and tooltip, so + // it never covers one. It is pinned to the selection, not movable, and egui stops a click at + // the topmost layer, so a click on the bar never reaches the document underneath. Clipped to + // the document area (`constrain_to` without `constrain`, which would push it inside), the part + // past its edge takes no clicks either, so the ribbon and the rulers under it keep them. + let area = egui::Area::new(egui::Id::new("wc_mini_toolbar")).order(Order::Middle).interactable(true); + let area = area.constrain_to(viewport).constrain(false).fixed_pos(bar.min); + area.show(ctx, |ui| { + ui.set_clip_rect(shown); let painter = ui.painter(); painter.rect_filled(bar.translate(Vec2::new(0.0, 2.0)), CornerRadius::same(6), t.page_shadow); painter.rect_filled(bar, CornerRadius::same(6), t.menu); @@ -427,4 +462,173 @@ mod tests { assert!((used.width() - inner.width()).abs() < 0.5, "the bar ({}) should be as wide as its controls ({})", inner.width(), used.width()); assert!(used.min.x - inner.min.x < 0.5, "the font box starts at the bar's left edge"); } + + /// The whole window over a document with `pick` selected, run until the bar is up. + fn bar_over(pick: &str) -> egui_kittest::Harness<'static, WordApp> { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "The quick brown fox jumps over the lazy dog"})).unwrap(); + a.run("select.text", json!({"text": pick})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + steps(&mut h); + h + } + + fn steps(h: &mut egui_kittest::Harness<'static, WordApp>) { + for _ in 0..6 { + h.step(); + } + } + + /// The bar's layer, when it was drawn last frame. + fn bar_layer(h: &egui_kittest::Harness<'static, WordApp>) -> Option { + h.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().find(|l| l.id == egui::Id::new("wc_mini_toolbar"))) + } + + fn click_at(h: &mut egui_kittest::Harness<'static, WordApp>, at: egui::Pos2) { + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + steps(h); + } + + fn format_state(h: &mut egui_kittest::Harness<'static, WordApp>) -> Value { + h.state_mut().session.run("format.state", &json!({})).unwrap() + } + + /// The bar was drawn on the Tooltip layer, over every drop-down: the ribbon's font size list + /// opened under it. Popups and menus are on Foreground; the bar must sit below them. + #[test] + fn the_bar_draws_below_popups() { + let h = bar_over("brown fox"); + let layer = bar_layer(&h).expect("the bar is shown over a selection"); + assert!(layer.order < Order::Foreground, "the bar is on {:?}, over the ribbon's drop-downs", layer.order); + } + + /// Turning to the ribbon puts the bar away (it covered the ribbon's drop-downs); it comes back + /// for the next selection, even one over the same text. + #[test] + fn a_click_on_the_ribbon_puts_the_bar_away_until_the_selection_changes() { + use egui_kittest::kittest::Queryable; + let mut h = bar_over("brown fox"); + assert!(visible(h.state()) && bar_layer(&h).is_some(), "the bar is up over the selection"); + // The ribbon's Bold is the topmost one; the bar has its own lower down. + let ribbon_bold = |h: &egui_kittest::Harness<'static, WordApp>| { + h.get_all_by_label("Bold").map(|n| n.rect().center()).min_by(|a, b| a.y.total_cmp(&b.y)).expect("the ribbon has Bold") + }; + let at = ribbon_bold(&h); + click_at(&mut h, at); + assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)), "the click ran the ribbon's Bold"); + assert!(!visible(h.state()), "a click on the ribbon puts the bar away"); + assert!(bar_layer(&h).is_none(), "the bar is no longer drawn"); + + h.state_mut().run("select.text", json!({"text": "lazy"})).unwrap(); + steps(&mut h); + assert!(visible(h.state()) && bar_layer(&h).is_some(), "a new selection brings the bar back"); + + let at = ribbon_bold(&h); + click_at(&mut h, at); + assert!(bar_layer(&h).is_none()); + h.state_mut().run("select.text", json!({"text": "quick"})).unwrap(); + steps(&mut h); + h.state_mut().run("select.text", json!({"text": "lazy"})).unwrap(); + steps(&mut h); + assert!(bar_layer(&h).is_some(), "selecting the same text again brings the bar back"); + } + + /// The ribbon's size list hangs over the page: picking from it is no press in the document, so + /// the bar stays away (it would cover the list again otherwise). + #[test] + fn picking_from_a_ribbon_list_over_the_page_keeps_the_bar_away() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let mut h = bar_over("brown fox"); + let size = format_state(&mut h).get("size").and_then(Value::as_f64).unwrap_or_default(); + let size = if size.fract() == 0.0 { format!("{size:.0}") } else { size.to_string() }; + // The ribbon's size box is the topmost one showing the size; the bar has its own. + let size_box = h.get_all_by_role(Role::TextInput).filter(|n| n.value().as_deref() == Some(size.as_str())).map(|n| n.rect()); + let size_box = size_box.min_by(|a, b| a.min.y.total_cmp(&b.min.y)).expect("the ribbon has a size box"); + click_at(&mut h, pos2(size_box.max.x + 8.0, size_box.center().y)); + assert!(bar_layer(&h).is_none(), "opening the ribbon's list puts the bar away"); + let page = h.state().canvas.canvas_rect.expect("the document is shown"); + let item = h.get_by_label("28").rect().center(); + assert!(page.contains(item), "the list's 28 ({item:?}) should hang over the page ({page:?}) for this test"); + click_at(&mut h, item); + assert_eq!(format_state(&mut h).get("size").and_then(Value::as_f64), Some(28.0), "the ribbon's size list applied 28 pt"); + assert!(!visible(h.state()) && bar_layer(&h).is_none(), "the bar stays away after a pick from the ribbon's list"); + } + + /// Scrolled until the selection went under the ribbon, the bar went with it and drew over the + /// ribbon and the ruler. It stays in the document area: the part past its edge neither shows + /// nor takes the pointer. + #[test] + fn the_bar_scrolls_under_the_ribbon_not_over_it() { + let mut h = bar_over("The quick"); + let page = h.state().canvas.canvas_rect.expect("the document is shown"); + // Scroll the selection's line to 45 points above the document area: the bar, below the + // line now, has its first row past the edge, over the ruler, and its second showing. + let target = page.min.y - 45.0; + let line = h.state().canvas.mini_anchor.expect("the selection is on screen"); + h.event(egui::Event::PointerMoved(page.center())); + h.step(); + let delta = egui::vec2(0.0, target - line.min.y); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase: egui::TouchPhase::Start, modifiers: Default::default() }); + h.step(); + h.state_mut().canvas.wheel = Default::default(); + steps(&mut h); + let line = h.state().canvas.mini_anchor.expect("the selection is still laid out"); + assert!((line.min.y - target).abs() < 1.0, "the line scrolled to {}, not {target}", line.min.y); + let [bar, _] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar still shows in part"); + assert!(bar.min.y < page.min.y && bar.max.y > page.min.y, "the bar {bar:?} should straddle the document's top {}", page.min.y); + + let drawn: Vec = + h.output().shapes.iter().filter(|s| matches!(&s.shape, egui::epaint::Shape::Rect(r) if r.rect == bar)).map(|s| s.clip_rect).collect(); + assert!(!drawn.is_empty(), "the bar's background is painted"); + for clip in drawn { + assert!(page.expand(0.5).contains_rect(clip), "the bar paints outside the document area {page:?}, over the ribbon: {clip:?}"); + } + + // The ruler's left-indent marker sits under the hidden part, at the line's start and the + // ruler's foot (`canvas::rulers`); dragging it still moves the indent. + let marker = pos2(line.min.x, page.min.y - 6.0); + assert!(bar.contains(marker), "the bar {bar:?} should cover the left-indent marker {marker:?}"); + h.event(egui::Event::PointerMoved(marker)); + h.event(egui::Event::PointerButton { pos: marker, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for dx in [6.0, 18.0, 36.0] { + h.event(egui::Event::PointerMoved(marker + egui::vec2(dx, 0.0))); + h.step(); + } + let end = marker + egui::vec2(36.0, 0.0); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + steps(&mut h); + let s = &h.state().session; + let indent = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props).indent_left).unwrap_or_default(); + assert!(indent > 0.0, "dragging the ruler's left-indent marker under the bar's hidden part left the indent at {indent}"); + } + + /// Picking from one of the bar's own lists (popups below it) keeps the bar up. + #[test] + fn picking_from_the_bars_own_list_keeps_it() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let mut h = bar_over("brown fox"); + let [bar, _] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar is shown over a selection"); + // Its two boxes are font and size; the size box's arrow is just right of the rightmost. + let size_box = + h.get_all_by_role(Role::TextInput).map(|n| n.rect()).filter(|r| bar.contains_rect(*r)).max_by(|a, b| a.min.x.total_cmp(&b.min.x)); + let size_box = size_box.expect("the bar has a size box"); + click_at(&mut h, pos2(size_box.max.x + 8.0, size_box.center().y)); + let twenty = h.get_by_label("20").rect().center(); + click_at(&mut h, twenty); + assert_eq!(format_state(&mut h).get("size").and_then(Value::as_f64), Some(20.0), "the bar's size list applied 20 pt"); + assert!(visible(h.state()) && bar_layer(&h).is_some(), "the bar stays up after its own list is used"); + } } diff --git a/crates/ui-egui/src/table_mouse.rs b/crates/ui-egui/src/table_mouse.rs index f98c08fa7..8cce90d9e 100644 --- a/crates/ui-egui/src/table_mouse.rs +++ b/crates/ui-egui/src/table_mouse.rs @@ -87,7 +87,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] } return true; } - if !available(app) || !(ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) { + if !(available(app) && ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) { return false; } let Some(p) = latest else { return false }; diff --git a/crates/ui-egui/src/table_resize.rs b/crates/ui-egui/src/table_resize.rs index 33cfd267d..add918d2a 100644 --- a/crates/ui-egui/src/table_resize.rs +++ b/crates/ui-egui/src/table_resize.rs @@ -150,7 +150,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] } return true; } - if !available(app) || !(ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) { + if !(available(app) && ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) { return false; } let Some(p) = latest else { return false }; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cb1fb4205..e4926439f 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-14 · **Change:** trivial (Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: Cross-reference dialog, #916; previously trivial: title rename and account settings; previously trivial: web Save Changes prompt waits for the save picker, #1315; previously trivial: AutoFit in a nested table, whole cells shaded for a selection through a table, #971; 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-14 · **Change:** trivial (Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; 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 @@ -66,6 +66,8 @@ Document title opens Rename Document; the account badge opens Options › Genera | 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) | | 2026-10-14 | trivial | File dialogs: the system Save As type list names each format, the document's own first (#1399) | | 2026-10-12 | trivial | Web: a pasted picture inserts only when the page is the edit target (page focused; no dialog, Backstage or text field such as a comment or Find box taking input), once; otherwise it is ignored (#1349) | +| 2026-10-13 | trivial | Mini toolbar: scrolled with its selection toward the ribbon (or the status bar), it slides under the rulers and ribbon with the text instead of drawing over them, and the part out of view takes no clicks | +| 2026-10-12 | trivial | Mini toolbar: a click on the ribbon (or anywhere outside the document) puts it away until the selection changes, so it never covers the ribbon's drop-downs; it now draws below every popup and menu instead of over them | | 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% | | 2026-10-11 | trivial | Rulers: table column and row markers drag their borders (one undo step; from the ruler only the column left of a border changes, with Shift the neighbour makes up the difference), and in a cell the indents and tabs are measured from it (#700) | | 2026-10-12 | trivial | Tables on the page: a double-click on a nested table's column border fits the column within the cell around the table; a selection from text into or across a table shades the whole cells of the rows it takes (#971) | From 3e9dde7799b2a7004d97f1e49a960dfefc8e5751 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 14 Oct 2026 22:24:09 +0200 Subject: [PATCH 124/199] Docs: encrypted DOCX row, duplicate document-grid rows, dated issue count (#1072) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- docs/file-format-parity.md | 6 ++++-- docs/layout-parity.md | 6 +++--- docs/target-app-parity.md | 7 ++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 202a57f2c..0f85c3657 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: DOCX save keeps unmodelled settings, #1034; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; 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-12 · **Last updated:** 2026-10-14 · **Change:** trivial (All Caps and Small Caps text extracts from PDFs as the capitals shown, #1473; previously trivial: password-protected DOCX row corrected, #55 #281; 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; @@ -34,7 +34,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | -| Encrypted/password-protected `.docx`/`.doc` | 1% | R/W | ❌ | ❌ | 0% | 6–10 | — | ECMA-376 Agile Encryption; `.doc` RC4/XOR detected and refused (`docbin` fib). Issue #55 | +| Encrypted/password-protected `.docx` | 1% | R/W | ✅ | ✅ | ~60% | 2–4 | `crates/docx/src/crypt.rs` (6 unit tests), `crates/engine/src/tests.rs::password_protected_documents_round_trip` | ECMA-376 / [MS-OFFCRYPTO] agile encryption (AES-256-CBC, SHA-512): a protected `.docx` opens with its password and saves encrypted (#55, #281). Synthetic files only; never checked against Word-made protected files | +| Encrypted/password-protected `.doc` (RC4/XOR) | <1% | R | ❌ | — | 0% | 4–6 | `crates/docbin/src/tests.rs::encrypted_rejected` | Detected and refused (`DocbinError::Encrypted`, `docbin` fib). Not decrypted | | `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources | | `.md` Markdown | — | (no) | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word | | `.tex` LaTeX | — | — | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word (#11), incl. math | @@ -191,6 +192,7 @@ in `w:compat` only `compatibilityMode` is replaced. Kept children that point at | 2026-10-14 | trivial | RTF import keeps sections: page size and orientation, margins, columns, break type, page numbering and each section's headers and footers (#1378) | | 2026-10-14 | trivial | `.doc` tables: cell shading read from every SHD record (`sprmTDefTableShd`/`ShdRaw` and their 2nd/3rd ranges for cells 22-62; Word 97 `Shd80` clear fills now show their background colour; exact colours win over the palette fallback), the row-end mark no longer becomes an extra empty column, and a table no longer inherits the previous table's grid, borders, alignment or indent (#1380). Horizontally merged rows (a row of fewer, wider cells) come with #1123 | | 2026-10-14 | minor | DOCX save merges `settings.xml` into the file's original: unmodelled settings (compatibility options, rsids, document variables, proofing state…) survive (#1034) | +| 2026-10-12 | trivial | Password-protected DOCX row corrected: agile-encrypted `.docx` opens and saves since #281 (was listed as unsupported); legacy encrypted `.doc` (RC4/XOR) split out, still refused | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index c86a812f4..9d99cb4e2 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-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a paragraph continued into the next column or page wraps around that column's shapes, #1374; previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; 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-12 · **Last updated:** 2026-10-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: duplicate document-grid row merged; 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, @@ -54,10 +54,9 @@ font can't match Word's metrics. | Line numbers (restart per page/section, count by) | ✅ | | — | | Vertical page alignment | ✅ | | — | | Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins (even pages put the inside margin and gutter on the right, #452; page-relative floats move with the text); book fold missing | 2–3 | -| Document grid (`w:docGrid`, lines per page, characters per line) | ❌ | East Asian documents paginate differently without it | 4–6 | | Vertical text (`tbRl`) in sections and text boxes; cell text direction | ✅ | cell text direction (#245); text-box text direction (#369); vertical sections (`tbRl`/`btLr`, #491): the page's text area turned, lines top to bottom stacking right to left, ideographs, kana and Hangul upright, Latin on its side, caret and clicks through the turn. Missing: OpenType `vert` alternates (punctuation is moved, not substituted), arrow keys along vertical lines, upright pictures take turned room | 2–4 | | Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins; book fold missing | 2–3 | -| Document grid (`w:docGrid`, lines per page, characters per line) | 🟡 | #391: line grid snaps line heights to whole pitches (text centred in them; `w:snapToGrid` off opts a paragraph out); character grid puts East Asian characters on the character pitch (`linesAndChars`) or all text (`snapToChars`), Latin text proportional; View › Gridlines draws it. Not yet: run-level `w:snapToGrid`, grid in headers/footers, paragraph spacing in grid lines, Word's exact glyph placement in a cell | 1–2 | +| Document grid (`w:docGrid`, lines per page, characters per line) | 🟡 | #391, #401: line grid snaps line heights to whole pitches (text centred in them; `w:snapToGrid` off opts a paragraph out); character grid puts East Asian characters on the character pitch (`linesAndChars`) or all text (`snapToChars`), Latin text proportional; View › Gridlines draws it. Not yet: run-level `w:snapToGrid`, grid in headers/footers, paragraph spacing in grid lines, Word's exact glyph placement in a cell | 1–2 | | Ruby / phonetic guide, enclose characters, combined characters | 🟡 | ruby (Phonetic Guide) landed (#288): ruby text over its base by alignment, line grows to fit, in PDF; enclose and combined characters missing; ruby not yet compared with Word page by page | 2–4 | | Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | | Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — | @@ -90,6 +89,7 @@ font can't match Word's metrics. | 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) | | 2026-10-14 | trivial | Colour emoji row set to 🟡: CBDT verified on Noto Color Emoji; COLR v0/v1 and sbix not yet seen on a real font (#1269) | | 2026-10-14 | trivial | Colour emoji and colour-font glyphs (COLR, sbix, CBDT) drawn on screen and in page renders (#1269) | +| 2026-10-12 | trivial | Document grid: the stale ❌ row removed; the 🟡 row (#391, #401) is the only one | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 301c22395..31466a5fc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Ink to Shape recognises arrows; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; 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: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** trivial (Ink to Shape recognises arrows; previously trivial: open-issue count dated 2026-10-10; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; 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: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -43,7 +43,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). and our knowledge of Word for Windows and Mac (Microsoft 365). Windows-only features (PDF Reflow open, Works/WordPerfect import) are counted, because a Word user on Windows expects them. - WordCraft as it is on origin/main today: source (catalog, command registry, readers/writers, - layout), tests, the i18n catalogs, open issues (62) and open PRs (~45), git history. + layout), tests, the i18n catalogs, open issues (62 as of 2026-10-10) and open PRs (~45 as of 2026-10-10), git history. - **Not done this pass:** opening real-world .docx files in both apps and comparing pages. There is no real-world corpus in the repo (Word-produced files can't be committed), and the disk budget ruled out building. That comparison is the first gap in [`gaps.md`](gaps.md). @@ -219,7 +219,7 @@ Word. Their weighted sum is the **full ready for real work** number. - **Why the hours went up** from "120–150 h to 100%" (2026-10-06): that figure left out localization, multilingual proofing, ecosystem, hardware, PDF Reflow and `.doc` writing, and priced DOCX corpus fidelity at 10 h. The new evidence is the Word bundle inventory (30 UI - languages, 120 proofing tools, 12 bibliography styles), the 62 open issues (crashes, freezes, + languages, 120 proofing tools, 12 bibliography styles), the 62 open issues as of 2026-10-10 (crashes, freezes, RTL reports) and the size of comparable work actually done (below). ### Calibration of hours (from this repo's history) @@ -252,6 +252,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Italian proofing: it-IT spelling, accent-first suggestions and conservative grammar; Proofing row updated, 42% → 44% | +| 2026-10-12 | trivial | The 62 open issues and ~45 open PRs cited as evidence are dated 2026-10-10 (the measurement pass); not re-counted (197 issues were open on 2026-10-12) | | 2026-10-11 | trivial | Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: column and row markers on the rulers (#700); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: the table context menu, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); Tables row updated, no percentage change | From 3962d397e507708038eaceda58c753265821eb3d Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Thu, 15 Oct 2026 02:24:28 +0600 Subject: [PATCH 125/199] Contributors: add Sazid Al Bayazid (albayazid) to people.toml (#1074) Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index c669b474d..9fa92e92b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -94,3 +94,6 @@ display_name = "Martin Ball" sync_github_name = false [people.Nghiathan13] + +[people.albayazid] +real_name = "Sazid Al Bayazid" From b8e46b244d08e690ae769c5bb83c76cdfe707ec7 Mon Sep 17 00:00:00 2001 From: Todd Pederson <34252100+Toddpe@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:24:40 -0400 Subject: [PATCH 126/199] UI: combo boxes join their dropdown arrow (field rounded left, arrow right) (#1083) * Clippy 1.96: simplify boolean guards, drop a needless drop() Rust 1.96's clippy flags nonminimal_bool in find_pattern, table_mouse and table_resize, and drop_non_drop in a ribbon test; `cargo xtask ci` failed at clippy on main. Same logic, rewritten as clippy suggests. Co-Authored-By: Claude Opus 5.5 * UI: combo boxes join their dropdown arrow (field rounded left, arrow right) The font and size boxes looked apart from their arrows: the text field was rounded on all four corners and the arrow on none, leaving a notch where they meet. The field now rounds only its left corners (in every widget state, including focus and hover) and the arrow only its right ones, so the two read as one control. Applies to every combo drawn by widgets::combo/font_combo: the ribbon's font, size and outline-level boxes, the mini toolbar, and the Font and Symbol dialogs. Co-Authored-By: Claude Opus 5.5 * UI: one divider between a combo's field and its arrow The field's right border and the arrow's left border stood side by side, a divider twice as wide as the rest of the outline. The arrow's box is now painted under the field and reaches one border width beneath it, so the field's right border is the only line between them (in the field's focus colour while typing). The test draws with the app's light and dark themes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/widgets.rs | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 590ac3161..ced55f7a0 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -537,6 +537,9 @@ pub fn font_matches(query: &str, items: &[String]) -> Vec { exact } +/// Width of a combo arrow's border, the field's own (the theme's input border is 1 pt). +const ARROW_BORDER: f32 = 1.0; + /// A searching combo's state between frames. #[derive(Clone, Default)] struct ComboSearch { @@ -573,6 +576,14 @@ fn combo_impl( let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + // The field and its arrow share one outline: rounded on the outside, square where they + // meet. The field's frame takes the widget visuals' radius in every state. + let r = ui.visuals().widgets.inactive.corner_radius; + let w = &mut ui.visuals_mut().widgets; + for v in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + v.corner_radius.ne = 0; + v.corner_radius.se = 0; + } // Up/Down move the list's highlight: take them before the text field moves its cursor. let mut step = 0i32; if s.open && ui.memory(|m| m.has_focus(te_id)) { @@ -586,6 +597,8 @@ fn combo_impl( }); } let te = egui::TextEdit::singleline(&mut text).id(te_id).desired_width(width - 16.0).font(regular(12.0)).margin(vec2(4.0, 3.0)); + // The arrow's box is painted here, under the field, once its size is known. + let arrow_bg = ui.painter().add(egui::Shape::Noop); let resp = ui.add(te); keytip_badge(ui, resp.rect, "combo", id); focused = resp.has_focus(); @@ -632,7 +645,13 @@ fn combo_impl( out = Some(text.clone()); } let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click()); - ui.painter().rect(ar, 0.0, if aresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + // It reaches one border width under the field, so the field's right border is the only line + // between them: two borders side by side read as a double-width divider. + let ar_radius = CornerRadius { nw: 0, sw: 0, ne: r.ne, se: r.se }; + let under = Rect::from_min_max(pos2(ar.min.x - ARROW_BORDER, ar.min.y), ar.max); + let fill = if aresp.hovered() { t.hover } else { t.input }; + let arrow_box = egui::epaint::RectShape::new(under, ar_radius, fill, Stroke::new(ARROW_BORDER, t.input_border), egui::StrokeKind::Inside); + ui.painter().set(arrow_bg, arrow_box); icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(DROPDOWN, DROPDOWN)), "dropdown", t.icon, t.accent); // The list drops from the box's left edge, as in Word, not from the arrow (#535). egui::Popup::menu(&aresp).anchor(resp.rect.union(ar)).show(|ui| { @@ -963,6 +982,58 @@ mod tests { assert_eq!(type_into_combo(false, &sizes, "1", &[Enter]).as_deref(), Some("1")); } + /// The field was rounded on all four corners and the arrow on none, so the two looked apart, + /// and their borders stood side by side as a double-width divider. The field is rounded only + /// on the left, the arrow only on the right, and the arrow's box lies under the field by one + /// border width, so a single line divides them; focused or not. + #[test] + fn combo_field_and_arrow_share_one_outline() { + let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); + let ctx = egui::Context::default(); + let cases = [crate::theme::Appearance::Light, crate::theme::Appearance::Dark].into_iter().flat_map(|a| [(a, false), (a, true)]); + for (appearance, focused) in cases { + crate::theme::apply(&ctx, appearance); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + if focused { + ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f")))); + } + combo(ui, "f", 150.0, "12", &sizes, None); + }); + // Painted boxes in paint order: (order, rect, corners, border width). + let boxes: Vec<(usize, Rect, CornerRadius, f32)> = std::mem::take(&mut out.shapes) + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Rect(r) => Some((r.rect, r.corner_radius, r.stroke.width)), + _ => None, + }) + .enumerate() + .map(|(i, (rect, c, w))| (i, rect, c, w)) + .collect(); + out.drop_without_applying_deltas(); + let corners: Vec = boxes.iter().map(|b| b.2).collect(); + let field = boxes.iter().find(|b| b.2.nw > 0 && b.2.sw == b.2.nw && b.2.ne == 0 && b.2.se == 0); + let field = field.unwrap_or_else(|| panic!("focused {focused}: no field rounded on the left only in {corners:?}")); + let arrow_corners = CornerRadius { nw: 0, sw: 0, ne: field.2.nw, se: field.2.sw }; + let arrow = boxes.iter().find(|b| b.2 == arrow_corners); + let arrow = arrow.unwrap_or_else(|| panic!("focused {focused}: no arrow rounded on the right only in {corners:?}")); + assert!(!corners.iter().any(|c| c.nw > 0 && c.ne > 0), "focused {focused}: a part is still rounded all round: {corners:?}"); + // One divider as wide as the other borders: the arrow's left border lies exactly under + // the field's right border, and the field is painted over it. + assert!(field.3 > 0.0 && arrow.3 == field.3, "focused {focused}: borders {} and {} differ", field.3, arrow.3); + assert!( + (arrow.1.min.x - (field.1.max.x - field.3)).abs() < 0.01, + "focused {focused}: arrow {:?} and field {:?} borders stand apart", + arrow.1, + field.1 + ); + assert!(arrow.0 < field.0, "focused {focused}: the arrow's box must be painted under the field"); + assert!( + (arrow.1.min.y - field.1.min.y).abs() < 0.01 && (arrow.1.max.y - field.1.max.y).abs() < 0.01, + "focused {focused}: the outlines' tops and bottoms differ" + ); + } + } + #[test] fn a_long_combo_list_builds_only_the_rows_in_view() { // #121: the font menu built (and so loaded the font of) every installed family. From e1f34a90d659a21ae6ecd6fcef882b8e1daaa38d Mon Sep 17 00:00:00 2001 From: witkowski01 Date: Wed, 14 Oct 2026 22:25:00 +0200 Subject: [PATCH 127/199] Proofing: a Dictionaries folder setting, and the dictionary each language found (#1063) (#1084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Options › Proofing gets a Dictionaries folder: a folder of Hunspell .aff/.dic files searched before the system locations, saved with the settings and applied at start-up. An app started from Finder or the Dock doesn't see WORDCRAFT_DICTIONARIES, so this is how it finds a dictionary kept in a folder of the user's own. Changing it reloads the Polish and Portuguese dictionaries and checks the text again. The pane lists the dictionary file each language found, or that none was found, so missing spelling marks are explained. Agents set it with file.options {"dictionariesFolder": path | "auto"}; a missing folder or a relative path is refused and changes nothing. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/options.rs | 49 +++++++++++++++- crates/engine/src/session.rs | 5 ++ crates/engine/src/tests.rs | 52 +++++++++++++++-- crates/proof/src/hr/mod.rs | 7 +++ crates/proof/src/it/mod.rs | 7 +++ crates/proof/src/lib.rs | 63 +++++++++++++++++++- crates/proof/src/pl/mod.rs | 7 +++ crates/proof/src/pt/mod.rs | 7 +++ crates/proof/src/tr/mod.rs | 7 +++ crates/ui-egui/src/i18n/bg.tsv | 8 +++ crates/ui-egui/src/i18n/cs.tsv | 8 +++ crates/ui-egui/src/i18n/de.tsv | 8 +++ crates/ui-egui/src/i18n/es.tsv | 8 +++ crates/ui-egui/src/i18n/et.tsv | 8 +++ crates/ui-egui/src/i18n/fr.tsv | 8 +++ crates/ui-egui/src/i18n/hu.tsv | 8 +++ crates/ui-egui/src/i18n/it.tsv | 8 +++ crates/ui-egui/src/i18n/ja.tsv | 8 +++ crates/ui-egui/src/i18n/pl.tsv | 6 ++ crates/ui-egui/src/i18n/pt-br.tsv | 8 +++ crates/ui-egui/src/i18n/ru.tsv | 8 +++ crates/ui-egui/src/i18n/sr-latn.tsv | 8 +++ crates/ui-egui/src/i18n/sr.tsv | 8 +++ crates/ui-egui/src/i18n/tr.tsv | 8 +++ crates/ui-egui/src/i18n/uk.tsv | 8 +++ crates/ui-egui/src/i18n/vi.tsv | 8 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 8 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 8 +++ crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/options.rs | 89 +++++++++++++++++++++++++++++ docs/localization-parity.md | 3 +- 31 files changed, 441 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index d9a96252e..a829f4c07 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -11,6 +11,8 @@ pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "languag /// Longest initials kept, in characters. pub const MAX_INITIALS_CHARS: usize = 9; +/// Longest dictionaries folder path taken, in characters. +const MAX_FOLDER_CHARS: usize = 4096; /// Longest word the custom dictionary takes, in characters. pub const MAX_WORD_CHARS: usize = 64; /// Most words one call may add or remove. @@ -20,7 +22,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("file.options", "Options", "File", options) .params( - r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#, + r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#, ) .pure(), CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| { @@ -113,6 +115,11 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { } Some(_) => return Err(CmdError::Params("`region` must be a locale tag like pl-PL, or auto".into())), }; + let folder = match v.get("dictionariesFolder") { + None | Some(Value::Null) => None, + Some(Value::String(f)) => Some(dictionaries_folder(f)?), + Some(_) => return Err(CmdError::Params("`dictionariesFolder` must be a folder path, or auto".into())), + }; let mut flag_values = Vec::new(); for (k, f) in flags() { if let Some(b) = bool_param(v, k)? { @@ -139,6 +146,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { || initials.is_some() || units.is_some() || region.is_some() + || folder.is_some() || !flag_values.is_empty() || !mark_values.is_empty() || check_spelling.is_some() @@ -156,6 +164,10 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { if let Some(r) = region { s.prefs.region = r; } + if let Some(f) = folder { + use_dictionaries_folder(f.as_deref()); + s.prefs.dictionaries_folder = f; + } for (f, b) in flag_values { *f(&mut s.prefs) = b; } @@ -208,5 +220,40 @@ pub fn current(s: &Session) -> Value { "ignoreNumbers": pr.ignore_numbers, "ignoreInternet": pr.ignore_internet, "dictionary": wordcraft_proof::user_dictionary(), + "dictionariesFolder": pr.dictionaries_folder, + "dictionaries": wordcraft_proof::installed_dictionaries() + .into_iter() + .map(|(lang, dic)| json!({"language": lang.code(), "file": dic.map(|d| d.to_string_lossy().into_owned())})) + .collect::>(), }) } + +/// The `dictionariesFolder` setting: `auto` (or nothing) forgets it; otherwise an existing +/// folder, given as an absolute path or one starting with `~/`. +fn dictionaries_folder(f: &str) -> Result, CmdError> { + let f = f.trim(); + if f.is_empty() || f.eq_ignore_ascii_case("auto") { + return Ok(None); + } + if f.chars().count() > MAX_FOLDER_CHARS || f.chars().any(char::is_control) { + return Err(CmdError::Params("`dictionariesFolder` isn't a folder path".into())); + } + let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(std::path::PathBuf::from); + let path = match (f.strip_prefix("~/"), home) { + (Some(rest), Some(h)) => h.join(rest), + _ => std::path::PathBuf::from(f), + }; + if !path.is_absolute() { + return Err(CmdError::Params(format!("`dictionariesFolder`: {f:?} isn't a full path"))); + } + if !path.is_dir() { + return Err(CmdError::Failed(format!("no folder at {}", path.display()))); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +/// Search `folder` for Hunspell dictionaries before the system locations, from now on (the saved +/// setting, applied when the app starts and when it changes). +pub fn use_dictionaries_folder(folder: Option<&str>) { + wordcraft_proof::set_dictionaries_folder(folder.map(std::path::PathBuf::from)); +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c0ed2d808..5d6aacaae 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -508,6 +508,10 @@ pub struct Prefs { /// The region the user chose for new documents, units and the decimal separator (a locale /// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system. pub region: Option, + /// A folder searched for Hunspell dictionaries before the system locations (File › Options › + /// Proofing, #1063): an app started from Finder or the Dock doesn't see + /// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only. + pub dictionaries_folder: Option, /// Formatting marks shown even while Show/Hide ¶ is off. pub marks: wordcraft_layout::display::FormattingMarks, /// Mark grammar errors as you type (with Check spelling as you type on). @@ -532,6 +536,7 @@ impl Default for Prefs { overtype: false, units: Default::default(), region: None, + dictionaries_folder: None, marks: Default::default(), mark_grammar: true, ignore_uppercase: true, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 328a4a349..362811c72 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4736,17 +4736,57 @@ fn note_options_per_section_and_convert_notes() { /// A tiny synthetic Polish Hunspell dictionary (written for these tests). Real dictionaries are /// installed on the system, never bundled; every Polish test installs this same one. fn polish_test_dictionary() { - let aff = "SET UTF-8\nTRY aioeznrwcyszżtkpdmłjlubąęgh\nMAP 2\nMAP oó\nMAP zż\nREP 2\nREP ż rz\nREP rz ż\n"; - let dic = "12\npłynie\nrzeka\na\nto\nwiem\nże\ntak\nw\nogóle\nzażółć\ngęślą\njaźń\n"; - wordcraft_proof::pl::install_dictionary(aff, dic).unwrap(); + wordcraft_proof::pl::install_dictionary(POLISH_TEST_AFF, POLISH_TEST_DIC).unwrap(); } +const POLISH_TEST_AFF: &str = "SET UTF-8\nTRY aioeznrwcyszżtkpdmłjlubąęgh\nMAP 2\nMAP oó\nMAP zż\nREP 2\nREP ż rz\nREP rz ż\n"; +const POLISH_TEST_DIC: &str = "12\npłynie\nrzeka\na\nto\nwiem\nże\ntak\nw\nogóle\nzażółć\ngęślą\njaźń\n"; + /// A tiny synthetic Portuguese Hunspell dictionary (written for these tests). Real dictionaries /// are installed on the system, never bundled; every Portuguese test installs this same one. fn portuguese_test_dictionary() { - let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n"; - let dic = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n"; - wordcraft_proof::pt::install_dictionary(aff, dic).unwrap(); + wordcraft_proof::pt::install_dictionary(PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC).unwrap(); +} + +const PORTUGUESE_TEST_AFF: &str = + "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n"; +const PORTUGUESE_TEST_DIC: &str = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n"; + +/// #1063: File › Options › Proofing › Dictionaries folder. A folder of Hunspell files is saved, +/// searched first and reported per language; a missing folder or a relative path is refused and +/// changes nothing. The folder holds the same test dictionaries the other tests install, so tests +/// running meanwhile see the same words; it stays set for the rest of the run for that reason. +#[test] +fn dictionaries_folder_option_is_saved_searched_and_reported() { + let dir = std::env::temp_dir().join(format!("wc-engine-dictionaries-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, aff, dic) in [("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC), ("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC)] { + std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); + std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); + } + let mut s = s(); + let missing = dir.join("no-such-folder"); + assert!(s.run("file.options", &json!({"dictionariesFolder": missing.to_string_lossy()})).is_err()); + assert!(s.run("file.options", &json!({"dictionariesFolder": "relative/dictionaries"})).is_err()); + assert!(s.run("file.options", &json!({"dictionariesFolder": 7})).is_err()); + assert_eq!(s.prefs.dictionaries_folder, None, "a refused folder changes nothing"); + let r = run(&mut s, "file.options", json!({"dictionariesFolder": dir.to_string_lossy()})); + assert_eq!(s.prefs.dictionaries_folder.as_deref(), Some(dir.to_string_lossy().as_ref())); + assert_eq!(r["dictionariesFolder"], json!(dir.to_string_lossy())); + let dicts = r["dictionaries"].as_array().unwrap(); + let file = |lang: &str| dicts.iter().find(|d| d["language"] == lang).map(|d| d["file"].clone()); + assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy()))); + assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy()))); + // Polish text is checked against the folder's dictionary. + run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "pl-PL"})); + let issues = run(&mut s, "review.issues", json!({})); + let words: Vec<&str> = issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str()).collect(); + assert_eq!(words, ["żeka"], "{issues}"); + // Saved with the settings. + let prefs = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(prefs["dictionariesFolder"], json!(dir.to_string_lossy())); } /// Review › Language and the proofing commands work in Portuguese end to end: the language makes diff --git a/crates/proof/src/hr/mod.rs b/crates/proof/src/hr/mod.rs index 05b5a88b2..5bbb55bc6 100644 --- a/crates/proof/src/hr/mod.rs +++ b/crates/proof/src/hr/mod.rs @@ -60,6 +60,13 @@ pub fn reload_dictionary() { *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; } +/// The `.dic` file of the Hunspell `hr_HR` dictionary found in the dictionaries folder or the +/// system locations now (File › Options › Proofing shows it, #1063); `None` when there is none. +/// A dictionary given with [`install_dictionary`] has no file and isn't reported. +pub fn installed_path() -> Option { + crate::hunspell::find(NAMES).map(|(_, dic)| dic) +} + /// Is a Croatian dictionary available (spelling is checked)? pub fn has_dictionary() -> bool { !dictionary().is_empty() diff --git a/crates/proof/src/it/mod.rs b/crates/proof/src/it/mod.rs index 41f63e489..7e1635599 100644 --- a/crates/proof/src/it/mod.rs +++ b/crates/proof/src/it/mod.rs @@ -66,6 +66,13 @@ pub fn reload_dictionary() { *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; } +/// The `.dic` file of the Hunspell `it_IT` dictionary found in the dictionaries folder or the +/// system locations now (File › Options › Proofing shows it, #1063); `None` when there is none. +/// A dictionary given with [`install_dictionary`] has no file and isn't reported. +pub fn installed_path() -> Option { + crate::hunspell::find(NAMES).map(|(_, dic)| dic) +} + /// Is an Italian dictionary available (spelling is checked)? pub fn has_dictionary() -> bool { !dictionary().is_empty() diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index d644b62f9..d285c5ba4 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -125,7 +125,33 @@ pub fn ignore_word(w: &str) { } } -/// A number that changes whenever a word is added to or removed from the user dictionary. +/// Search `dir` for Hunspell dictionaries before the system locations (File › Options › +/// Proofing › Dictionaries folder, #1063); `None` clears it. The dictionaries load again from +/// the new search path, and text is checked again. +pub fn set_dictionaries_folder(dir: Option) { + hunspell::set_dictionary_dir(dir); + pl::reload_dictionary(); + pt::reload_dictionary(); + it::reload_dictionary(); + hr::reload_dictionary(); + tr::reload_dictionary(); + DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed); +} + +/// Each language that reads an installed Hunspell dictionary, with the `.dic` file found for it +/// now (`None`: not installed, so the language has no spelling marks). +pub fn installed_dictionaries() -> Vec<(ProofLang, Option)> { + vec![ + (ProofLang::Pl, pl::installed_path()), + (ProofLang::Pt, pt::installed_path()), + (ProofLang::It, it::installed_path()), + (ProofLang::Hr, hr::installed_path()), + (ProofLang::Tr, tr::installed_path()), + ] +} + +/// A number that changes whenever a word is added to or removed from the user dictionary, or the +/// dictionaries folder changes. pub fn dictionary_generation() -> u64 { DICTIONARY_GENERATION.load(Ordering::Relaxed) } @@ -748,6 +774,41 @@ fn starts_consonant_sound(w: &str) -> bool { mod tests { use super::*; + /// #1063: the dictionaries folder is searched before the system locations; the dictionaries + /// in it are reported and used, and cached checks are dropped. The folder holds the same test + /// dictionaries the other tests install, so tests running meanwhile see the same words; it + /// stays set for the rest of the run for that reason. + #[test] + fn dictionaries_folder_is_searched_first() { + let dir = std::env::temp_dir().join(format!("wc-proof-dictionaries-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, aff, dic) in [ + ("pl_PL", pl::test_dict::AFF, pl::test_dict::DIC), + ("pt_BR", pt::test_dict::AFF, pt::test_dict::DIC), + ("it_IT", it::test_dict::AFF, it::test_dict::DIC), + ("hr_HR", hr::test_dict::AFF, hr::test_dict::DIC), + ] { + std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); + std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); + } + let before = dictionary_generation(); + set_dictionaries_folder(Some(dir.clone())); + assert!(dictionary_generation() > before, "cached checks are dropped"); + // Turkish has no test dictionary to put in the folder; it's listed after the others. + let found = installed_dictionaries(); + assert_eq!(found.last().map(|(lang, _)| *lang), Some(ProofLang::Tr)); + assert_eq!( + found[..4], + [ + (ProofLang::Pl, Some(dir.join("pl_PL.dic"))), + (ProofLang::Pt, Some(dir.join("pt_BR.dic"))), + (ProofLang::It, Some(dir.join("it_IT.dic"))), + (ProofLang::Hr, Some(dir.join("hr_HR.dic"))), + ] + ); + assert!(is_correct_in("chrząszcz", ProofLang::Pl) && !is_correct_in("chrzonszcz", ProofLang::Pl)); + } + #[test] fn dictionary_loads() { assert!(dict::Dictionary::en_us().len() > 100_000); diff --git a/crates/proof/src/pl/mod.rs b/crates/proof/src/pl/mod.rs index 74d8b8d2f..227253408 100644 --- a/crates/proof/src/pl/mod.rs +++ b/crates/proof/src/pl/mod.rs @@ -67,6 +67,13 @@ pub fn reload_dictionary() { *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; } +/// The `.dic` file of the installed Polish dictionary, searching the dictionaries folder and +/// the system locations now (File › Options › Proofing shows it, #1063); `None` when there is +/// none. A dictionary given with [`install_dictionary`] has no file and isn't reported. +pub fn installed_path() -> Option { + crate::hunspell::find(NAMES).map(|(_, dic)| dic) +} + /// Is a Polish dictionary available (spelling is checked)? pub fn has_dictionary() -> bool { !dictionary().is_empty() diff --git a/crates/proof/src/pt/mod.rs b/crates/proof/src/pt/mod.rs index be16c74b8..d175f63ce 100644 --- a/crates/proof/src/pt/mod.rs +++ b/crates/proof/src/pt/mod.rs @@ -66,6 +66,13 @@ pub fn reload_dictionary() { *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; } +/// The `.dic` file of the installed Portuguese dictionary, searching the dictionaries folder and +/// the system locations now (File › Options › Proofing shows it, #1063); `None` when there is +/// none. A dictionary given with [`install_dictionary`] has no file and isn't reported. +pub fn installed_path() -> Option { + crate::hunspell::find(NAMES).map(|(_, dic)| dic) +} + /// Is a Portuguese dictionary available (spelling is checked)? pub fn has_dictionary() -> bool { !dictionary().is_empty() diff --git a/crates/proof/src/tr/mod.rs b/crates/proof/src/tr/mod.rs index e0b6fab9c..2b18158ab 100644 --- a/crates/proof/src/tr/mod.rs +++ b/crates/proof/src/tr/mod.rs @@ -65,6 +65,13 @@ pub fn reload_dictionary() { *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; } +/// The `.dic` file of the Hunspell `tr_TR` dictionary found in the dictionaries folder or the +/// system locations now (File › Options › Proofing shows it, #1063); `None` when there is none. +/// A dictionary given with [`install_dictionary`] has no file and isn't reported. +pub fn installed_path() -> Option { + crate::hunspell::find(NAMES).map(|(_, dic)| dic) +} + /// Is a Turkish dictionary available? pub fn has_dictionary() -> bool { !dictionary().is_empty() diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f69f15126..105207382 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2517,3 +2517,11 @@ Heading text Текст на заглавието Insert reference to: Вмъкване на препратка към: Reference type: Тип препратка: + +# Dictionaries folder (#1063) + Dictionaries Речници + Dictionaries folder: Папка с речници: + Not set: the system's usual places Не е зададена: обичайните места в системата + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Файловете .aff и .dic на Hunspell от тази папка (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) се използват преди системните. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: не е намерен речник diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c674cf156..fb0fe633f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2521,3 +2521,11 @@ preview Title Ukázka Heading text Text nadpisu Insert reference to: Vložit odkaz na: Reference type: Typ odkazu: + +# Dictionaries folder (#1063) + Dictionaries Slovníky + Dictionaries folder: Složka se slovníky: + Not set: the system's usual places Nenastaveno: obvyklá umístění v systému + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Soubory Hunspell .aff a .dic z této složky (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) mají přednost před systémovými. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: slovník nebyl nalezen diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 16aa52473..f59d53bd3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2129,3 +2129,11 @@ Suggested Vorschläge Set Proofing Language… Sprache für die Korrekturhilfen festlegen… Language Preferences… Spracheinstellungen… + +# Dictionaries folder (#1063) + Dictionaries Wörterbücher + Dictionaries folder: Wörterbuchordner: + Not set: the system's usual places Nicht festgelegt: die üblichen Orte des Systems + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Hunspell-Dateien (.aff und .dic) in diesem Ordner (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) haben Vorrang vor denen des Systems. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: kein Wörterbuch gefunden diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff8c55bb5..4d91b4c42 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2496,3 +2496,11 @@ preview Title Título Suggested Sugerencias Set Proofing Language… Establecer idioma de revisión… Language Preferences… Preferencias de idioma… + +# Dictionaries folder (#1063) + Dictionaries Diccionarios + Dictionaries folder: Carpeta de diccionarios: + Not set: the system's usual places Sin definir: las ubicaciones habituales del sistema + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Los archivos .aff y .dic de Hunspell de esta carpeta (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) se usan antes que los del sistema. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: no se encontró ningún diccionario diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index b62cfe6a5..c897c8a22 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2164,3 +2164,11 @@ Suggested Soovitused Set Proofing Language… Määra õigekeelsuskontrolli keel… Language Preferences… Keele-eelistused… + +# Dictionaries folder (#1063) + Dictionaries Sõnastikud + Dictionaries folder: Sõnastike kaust: + Not set: the system's usual places Määramata: süsteemi tavapärased asukohad + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Selle kausta Hunspelli .aff- ja .dic-failid (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) on eelistatud süsteemi omadele. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: sõnastikku ei leitud diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c7ee4e543..2802433da 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2138,3 +2138,11 @@ Suggested Suggestions Set Proofing Language… Définir la langue de vérification… Language Preferences… Préférences linguistiques… + +# Dictionaries folder (#1063) + Dictionaries Dictionnaires + Dictionaries folder: Dossier des dictionnaires : + Not set: the system's usual places Non défini : emplacements habituels du système + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Les fichiers Hunspell .aff et .dic de ce dossier (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) sont utilisés avant ceux du système. + {language}: {file} {language} : {file} + {language}: no dictionary found {language} : aucun dictionnaire trouvé diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 17406e917..32f06d9a6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2526,3 +2526,11 @@ Heading text Címsor szövege Insert reference to: Hivatkozás beszúrása erre: Reference type: Hivatkozás típusa: + +# Dictionaries folder (#1063) + Dictionaries Szótárak + Dictionaries folder: Szótárak mappája: + Not set: the system's usual places Nincs beállítva: a rendszer szokásos helyei + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Az ebben a mappában lévő Hunspell .aff és .dic fájlok (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) elsőbbséget élveznek a rendszer fájljaival szemben. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: nem található szótár diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a53c7ebef..1edcdbcd7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2512,3 +2512,11 @@ preview Title Esempio Heading text Testo del titolo Insert reference to: Inserisci riferimento a: Reference type: Tipo di riferimento: + +# Dictionaries folder (#1063) + Dictionaries Dizionari + Dictionaries folder: Cartella dei dizionari: + Not set: the system's usual places Non impostata: i percorsi abituali del sistema + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. I file Hunspell .aff e .dic in questa cartella (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) hanno la precedenza su quelli di sistema. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: nessun dizionario trovato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 378a05b79..4c120c1f7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2130,3 +2130,11 @@ Suggested おすすめ Set Proofing Language… 校正言語の設定… Language Preferences… 言語の設定… + +# Dictionaries folder (#1063) + Dictionaries 辞書 + Dictionaries folder: 辞書フォルダー: + Not set: the system's usual places 未設定: システムの標準の場所 + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. このフォルダーの Hunspell .aff と .dic ファイル (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) は、システムのものより優先して使われます。 + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: 辞書が見つかりません diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2fb5ec15d..9661adf1b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2521,3 +2521,9 @@ preview Title Tytuł Heading text Tekst nagłówka Insert reference to: Wstaw odwołanie do: Reference type: Typ odwołania: + Dictionaries Słowniki + Dictionaries folder: Folder słowników: + Not set: the system's usual places Nie ustawiono: standardowe miejsca w systemie + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Pliki Hunspell .aff i .dic z tego folderu (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) mają pierwszeństwo przed systemowymi. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: nie znaleziono słownika diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 59f2905f2..349fb8bd3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2142,3 +2142,11 @@ Suggested Sugestões Set Proofing Language… Definir idioma de revisão… Language Preferences… Preferências de idioma… + +# Dictionaries folder (#1063) + Dictionaries Dicionários + Dictionaries folder: Pasta de dicionários: + Not set: the system's usual places Não definida: os locais habituais do sistema + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Os arquivos .aff e .dic do Hunspell nesta pasta (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) são usados antes dos do sistema. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: nenhum dicionário encontrado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 85c888ff4..1b7bfe6e4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2527,3 +2527,11 @@ preview Title Пример Heading text Текст заголовка Insert reference to: Вставить ссылку на: Reference type: Тип ссылки: + +# Dictionaries folder (#1063) + Dictionaries Словари + Dictionaries folder: Папка словарей: + Not set: the system's usual places Не задана: стандартные расположения в системе + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Файлы Hunspell .aff и .dic из этой папки (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) используются раньше системных. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: словарь не найден diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 38177fe9f..eb49545c7 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2140,3 +2140,11 @@ Suggested Predlozi Set Proofing Language… Postavi jezik provere… Language Preferences… Jezičke postavke… + +# Dictionaries folder (#1063) + Dictionaries Rečnici + Dictionaries folder: Fascikla rečnika: + Not set: the system's usual places Nije podešeno: uobičajene lokacije u sistemu + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Hunspell .aff i .dic datoteke iz ove fascikle (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) koriste se pre sistemskih. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: rečnik nije pronađen diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e11e2867b..8a5137902 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2144,3 +2144,11 @@ Suggested Предлози Set Proofing Language… Постави језик провере… Language Preferences… Језичке поставке… + +# Dictionaries folder (#1063) + Dictionaries Речници + Dictionaries folder: Фасцикла речника: + Not set: the system's usual places Није подешено: уобичајене локације у систему + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Hunspell .aff и .dic датотеке из ове фасцикле (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) користе се пре системских. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: речник није пронађен diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c2206d50c..a00f2e996 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2526,3 +2526,11 @@ preview Title Örnek Heading text Başlık metni Insert reference to: Başvuru ekle: Reference type: Başvuru türü: + +# Dictionaries folder (#1063) + Dictionaries Sözlükler + Dictionaries folder: Sözlük klasörü: + Not set: the system's usual places Ayarlanmadı: sistemin olağan konumları + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Bu klasördeki Hunspell .aff ve .dic dosyaları (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) sistemdekilerden önce kullanılır. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: sözlük bulunamadı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index adde1b827..7abb7d00c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2142,3 +2142,11 @@ Suggested Пропоновані Set Proofing Language… Вибрати мову перевірки правопису… Language Preferences… Мовні параметри… + +# Dictionaries folder (#1063) + Dictionaries Словники + Dictionaries folder: Папка словників: + Not set: the system's usual places Не задано: стандартні розташування в системі + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Файли Hunspell .aff і .dic із цієї папки (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) використовуються раніше за системні. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: словник не знайдено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3ce84722c..b38a4091b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2548,3 +2548,11 @@ # Ctrl+drag copies (#1046) Copy to Sao chép tới + +# Dictionaries folder (#1063) + Dictionaries Từ điển + Dictionaries folder: Thư mục từ điển: + Not set: the system's usual places Chưa đặt: các vị trí thông thường của hệ thống + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Các tệp Hunspell .aff và .dic trong thư mục này (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) được dùng trước các tệp của hệ thống. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: không tìm thấy từ điển diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 39ce3c257..5717aa1b6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2142,3 +2142,11 @@ Suggested 建议 Set Proofing Language… 设置校对语言… Language Preferences… 语言首选项… + +# Dictionaries folder (#1063) + Dictionaries 词典 + Dictionaries folder: 词典文件夹: + Not set: the system's usual places 未设置:系统的常用位置 + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. 此文件夹中的 Hunspell .aff 和 .dic 文件 (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) 优先于系统中的文件。 + {language}: {file} {language}:{file} + {language}: no dictionary found {language}:未找到词典 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 50026c454..f3aa21b86 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2131,3 +2131,11 @@ Suggested 建議 Set Proofing Language… 設定校訂語言… Language Preferences… 語言喜好設定… + +# Dictionaries folder (#1063) + Dictionaries 字典 + Dictionaries folder: 字典資料夾: + Not set: the system's usual places 未設定:系統的一般位置 + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. 此資料夾中的 Hunspell .aff 與 .dic 檔案 (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) 優先於系統中的檔案。 + {language}: {file} {language}:{file} + {language}: no dictionary found {language}:找不到字典 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3f63d2710..6f030d301 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -692,6 +692,10 @@ impl WordApp { } } self.session.prefs = std::mem::take(&mut self.ui.editing); + // The saved dictionaries folder (#1063) is searched from the start. + if let Some(f) = self.session.prefs.dictionaries_folder.as_deref() { + wordcraft_engine::cmd::options::use_dictionaries_folder(Some(f)); + } self.session.read_aloud.set_rate(self.ui.read_aloud_rate); self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations; self.session.view.dark_mode = self.ui.dark_page; diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 5f97efc29..635757eca 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -266,6 +266,76 @@ fn proofing(app: &mut WordApp, ui: &mut Ui) { ui.add_space(8.0); section(ui, "Custom dictionary"); custom_dictionary(app, ui); + ui.add_space(8.0); + section(ui, "Dictionaries"); + dictionaries(app, ui); +} + +/// Where the spelling dictionaries come from (#1063): a folder searched before the system's +/// usual places, saved with the settings, and the dictionary each language found. An app started +/// from Finder or the Dock doesn't see `WORDCRAFT_DICTIONARIES`, so this is how it finds a +/// dictionary kept in a folder of the user's own. +fn dictionaries(app: &mut WordApp, ui: &mut Ui) { + let saved = app.session.prefs.dictionaries_folder.clone().unwrap_or_default(); + let id = egui::Id::new("options_dictionaries_folder"); + let mut folder = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| saved.clone()); + ui.label(tl!("Dictionaries folder:")); + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(tl!("Not set: the system's usual places"))); + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let changed = folder.trim() != saved; + if ui.add_enabled(changed, egui::Button::new(tl!("Apply"))).clicked() || (enter && changed) { + let value = if folder.trim().is_empty() { "auto" } else { folder.trim() }; + match app.run("file.options", json!({"dictionariesFolder": value})) { + // Shown as saved (`~/` expanded). + Ok(_) => folder = app.session.prefs.dictionaries_folder.clone().unwrap_or_default(), + Err(e) => app.status(e), + } + } + if ui.add_enabled(!saved.is_empty(), egui::Button::new(tl!("Clear"))).clicked() + && app.run("file.options", json!({"dictionariesFolder": "auto"})).is_ok() + { + folder.clear(); + } + }); + ui.data_mut(|d| d.insert_temp(id, folder)); + note(ui, "Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's."); + // The dictionary each language found, looked up again after the folder changes. + let found_id = egui::Id::new("options_dictionaries_found"); + let generation = wordcraft_engine::proof::dictionary_generation(); + let cached = ui.data(|d| d.get_temp::<(u64, Vec<(String, Option)>)>(found_id)).filter(|(g, _)| *g == generation); + let found = match cached { + Some((_, f)) => f, + None => { + let f: Vec<(String, Option)> = wordcraft_engine::proof::installed_dictionaries() + .into_iter() + .map(|(lang, dic)| (language_name(lang).to_string(), dic.map(|d| d.to_string_lossy().into_owned()))) + .collect(); + ui.data_mut(|d| d.insert_temp(found_id, (generation, f.clone()))); + f + } + }; + for (language, file) in found { + let line = match file { + Some(f) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &f)]), + None => crate::i18n::fmt(tl!("{language}: no dictionary found"), &[("language", &language)]), + }; + ui.add(egui::Label::new(RichText::new(line).small()).truncate()); + } +} + +/// The interface name of a language with a Hunspell dictionary. +fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str { + use wordcraft_engine::proof::ProofLang; + match lang { + ProofLang::Pl => tl!("Polish"), + ProofLang::Pt => tl!("Portuguese (Brazil)"), + ProofLang::It => tl!("Italian"), + ProofLang::Hr => tl!("Croatian"), + ProofLang::Tr => tl!("Turkish"), + // English uses its bundled word list; Bulgarian has no dictionary yet. + ProofLang::En | ProofLang::Bg | ProofLang::Other => "", + } } /// The custom dictionary: the words Add to Dictionary kept, each with a Remove button, and a @@ -686,6 +756,22 @@ mod tests { assert!(UiState::from_json(b"[1, 2]").is_none() && UiState::from_json(b"not json").is_none()); } + /// The dictionaries folder (#1063) is saved with the settings and searched again after a + /// restart, before any document is checked. + #[test] + fn the_dictionaries_folder_survives_a_restart() { + let dir = std::env::temp_dir().join(format!("wc-ui-dictionaries-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut a = app(); + a.run("file.options", json!({"dictionariesFolder": dir.to_string_lossy()})).unwrap(); + let saved = serde_json::to_vec(&a.prefs()).unwrap(); + wordcraft_engine::proof::hunspell::set_dictionary_dir(None); + let mut b = app(); + b.apply_prefs(UiState::from_json(&saved).unwrap()); + assert_eq!(b.session.prefs.dictionaries_folder.as_deref(), Some(dir.to_string_lossy().as_ref())); + assert_eq!(wordcraft_engine::proof::hunspell::search_dirs(None).first(), Some(&dir), "searched first"); + } + /// Settings saved before #838 kept the unit under `editing.units` (default inches): a unit /// the user chose carries over to `measurementUnits`; inches, junk or a newer value don't. #[test] @@ -732,6 +818,9 @@ mod tests { h.get_by_label("Ignore words in capitals").click(); h.run_steps(2); assert!(!h.state().session.prefs.ignore_uppercase); + // #1063: the dictionaries folder and the dictionary each language found. + assert!(h.query_by_label("Dictionaries folder:").is_some()); + assert!(h.query_by_label_contains("Polish: ").is_some(), "the Polish dictionary's line"); h.get_by_label("Advanced").click(); h.run_steps(4); h.get_by_label("Typing replaces selected text").click(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 71174c779..c3d907622 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -99,7 +99,7 @@ other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). | Language | Spelling | Suggestions | Grammar | Hyphenation | |---|---|---|---|---| | English (`en`, any region; text with no language) | Moby word list (public domain, ~160k words) with inflection rules | edit distance | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic | -| Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, or the `WORDCRAFT_DICTIONARIES` folder; not bundled, so no Polish spelling marks without it), read at runtime by a Hunspell-style affix engine (`crates/proof/src/affix.rs`); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet | +| Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, the folder set in File › Options › Proofing › Dictionaries folder, or `WORDCRAFT_DICTIONARIES`; not bundled, so no Polish spelling marks without it; the same pane shows which file each language found), read at runtime by a Hunspell-style affix engine (`crates/proof/src/affix.rs`); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet | | Portuguese (`pt`, any region) | The Hunspell `pt_BR` dictionary installed on the system — VERO and its derivatives (same places as Polish; not bundled, LGPL/MPL), read at runtime by the same affix engine, which reads its `FLAG UTF-8`, 106 flags, continuation classes and `FORBIDDENWORD` spellings; names capitalized, compounds part by part (`guarda-roupa`, `ex-marido`) | missing accents first (`coraçao` → `coração`, `saude` → `saúde`), the dictionary's confusion table (`seje` → `seja`, `excessão` → `exceção`), missing space (`nãoé` → `não é`), one typo, then an accent-folding stem scan | Portuguese messages: repeated word (the emphatic doubles are allowed), space before punctuation, double space, sentence capital (knows `etc.`, `pág.`, `Dr.`, initials, ordinals, dialogue dashes), doubled punctuation; no comma rule — `que` opens a clause only sometimes | TeX Portuguese patterns (Rezende, Almeida, Araujo, Benevides; BSD 3-clause) with their 2 exception words | | Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | | Turkish (`tr`, any region) | The Hunspell `tr_TR` dictionary installed on the system (same places as Polish; not bundled), read at runtime; proper nouns capitalized, apostrophe suffixes (`Ankara'da`, `WordCraft'ta`) and hyphenated compounds | missing diacritics / ASCII restoration first (`turkce` → `Türkçe`, `isik` → `ışık`), common confusion table (`yalnış` → `yanlış`, `herşey` → `her şey`), detached suffixes (`bende` → `ben de`, `geldinmi` → `geldi mi`), one typo | Turkish messages: repeated word (idiomatic reduplications allowed: `yavaş yavaş`), space before punctuation, double space, sentence capital (knows `vb.`, `vs.`, `prof.`, `dr.`), no comma before/after conjunctions (`ve`, `veya`, `de`), doubled punctuation | Turkish syllable rules (TDK rules); no external pattern file or word list needed | @@ -177,6 +177,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | File › Options › Proofing › Dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) | | 2026-10-12 | minor | Czech interface catalog completed: 386 entries added (2,447 total), including built-in style names, numbered families, equation gallery, watermarks, paper sizes and preview tiles; coverage test requires every Russian and Spanish key and retains Chinese coverage | | 2026-10-12 | minor | Italian proofing: it-IT spelling from the installed Hunspell dictionary (system `it_IT`, never bundled — upstream is GPL), accent-first suggestions, conservative grammar with Italian messages, TeX Italian hyphenation patterns (MIT option); Review › Language already offered Italian; an `it` interface writes it-IT documents | | 2026-10-13 | minor | Interface audit back in CI (#1128): Envelopes, Labels and Layout › Size labels go through translated patterns with the region's decimal separator (the old fixed-format paper rows, dead since #838, removed); 20 strings added to all 18 catalogs (status-bar messages, Reading Mode toolbar, Go To, Search's Suggested); citation style names and `bad parameters:` errors exempt; the Read Aloud player no longer draws a speech error before the frame has polled it, the audit's rare failure under load | From 2215e52aa466a19e0741a633cc39b0f61e0a0aac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 16:37:03 -0400 Subject: [PATCH 128/199] CI: park the mini-toolbar scroll test that #1501 and #1069 disagree on #1069's the_bar_scrolls_under_the_ribbon_not_over_it expects the bar to slide under the ribbon with a selection scrolled above the document; #1501 (landed first) anchors the bar to the visible selection only, so the selection has no anchor there and the test fails on main. Ignored with the reason until the owner picks the behaviour. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/mini_toolbar.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index d4c187759..461ff06cf 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -569,6 +569,10 @@ mod tests { /// ribbon and the ruler. It stays in the document area: the part past its edge neither shows /// nor takes the pointer. #[test] + // #1069 lets the bar slide under the ribbon with a selection scrolled past the document's + // top; #1501, landed first, anchors it to the visible part of the selection only and drops it + // once none shows. Which one Word does is the owner's call; until then this one is off. + #[ignore = "conflicts with #1501's visible-selection anchor; see the comment above"] fn the_bar_scrolls_under_the_ribbon_not_over_it() { let mut h = bar_over("The quick"); let page = h.state().canvas.canvas_rect.expect("the document is shown"); From 77da439d0b3099949687edaca1e582d93bbc5812 Mon Sep 17 00:00:00 2001 From: przemek-arch Date: Wed, 14 Oct 2026 22:55:15 +0200 Subject: [PATCH 129/199] DOCX: carry unmodelled parts; keep comment ids, style defaults, latent styles, list ids and app props (#1034) (#1060) * DOCX: merge settings.xml into the original instead of regenerating it (#1034) A save regenerated word/settings.xml from the model, so every setting the model doesn't hold was dropped: compatibility options (lost in 18 of 18 real-world Word files), rsids, document variables, proofing state and the colour scheme mapping. The reader now keeps the original part when it holds such settings, and the writer merges into it: the original root and every unowned child stay byte for byte; owned children follow the model in CT_Settings order; in w:compat only compatibilityMode is replaced. The privacy options are the model's since #1062, so they are owned too. Kept children that point at a relationship (a mail-merge data source) are dropped. Anything unexpected falls back to the generated part. Files WordCraft wrote itself keep nothing extra. Co-Authored-By: Claude Opus 5.5 * DOCX: carry unmodelled parts and keep comment ids, style defaults, latent styles, list ids and app props (#1034) Builds on the settings.xml merge. - Parts the package or main document relate to that WordCraft neither models nor writes (webSettings, stylesWithEffects, people, add-in task panes, the thumbnail, future parts) are carried verbatim with their relationships and content types; relationship types the writer produces, and any the body refers to by id, are not (HANDLED_RELS). - commentsIds / commentsExtensible: durable ids and UTC dates are read into the model and regenerated against the written paragraph ids. - w:default on localized default styles, w:latentStyles, lists' nsid/tmpl/multiLevelType, and app.xml Template/Company/Manager/ HyperlinkBase round-trip; counts are not carried, and Company/Manager are dropped when the file asks to remove personal information. - Comment reference marks use the document's "annotation reference" style id instead of a hard-coded CommentReference. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 7 + crates/doc/src/numbering.rs | 13 +- crates/doc/src/styles.rs | 17 +- crates/docbin/src/list.rs | 3 +- crates/docx/src/package.rs | 14 ++ crates/docx/src/read/embed.rs | 74 +++++++ crates/docx/src/read/mod.rs | 84 ++++++- crates/docx/src/write/embed.rs | 24 +- crates/docx/src/write/mod.rs | 110 ++++++++- crates/docx/src/write/story.rs | 13 +- crates/docx/tests/passthrough.rs | 344 +++++++++++++++++++++++++++++ crates/docx/tests/roundtrip.rs | 21 +- crates/engine/src/cmd/lists.rs | 2 +- crates/engine/src/cmd/review.rs | 9 +- crates/engine/src/cmd/templates.rs | 2 +- crates/formats/src/model.rs | 2 +- docs/file-format-parity.md | 24 +- 17 files changed, 740 insertions(+), 23 deletions(-) create mode 100644 crates/docx/tests/passthrough.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bdadbed17..919d66ef2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -322,6 +322,13 @@ pub struct Comment { pub resolved: bool, /// `Document::parts` id of the comment text. pub part: u32, + /// Word's durable id for the comment (`w16cid:durableId`, hex), kept across saves so Word + /// keeps tracking the same comment. `None` for comments made here: the writer assigns one. + #[serde(skip_serializing_if = "Option::is_none")] + pub durable_id: Option, + /// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there. + #[serde(skip_serializing_if = "Option::is_none")] + pub date_utc: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index 052893450..29121118c 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -75,6 +75,17 @@ pub struct AbstractNum { pub id: u32, pub name: Option, pub levels: Vec, + /// Word's list identity (`w:nsid`, 8 hex digits), which it uses to match lists across + /// documents (paste, compare); kept from the file. + #[serde(skip_serializing_if = "Option::is_none")] + pub nsid: Option, + /// The list template the definition came from (`w:tmpl`); kept from the file. + #[serde(skip_serializing_if = "Option::is_none")] + pub tmpl: Option, + /// `w:multiLevelType` from the file (`singleLevel`, `multilevel`, `hybridMultilevel`); + /// `None`: derived from the levels. + #[serde(skip_serializing_if = "Option::is_none")] + pub multi_level_type: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] @@ -222,7 +233,7 @@ impl Numbering { /// Create a new list of `kind`; returns the `num` id. pub fn add_list(&mut self, kind: ListKind) -> u32 { let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0); - self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) }); + self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind), ..Default::default() }); let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1); self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() }); nid diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 4c9a398c4..179a44212 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -41,6 +41,10 @@ pub struct Style { /// (`w:locked`). #[serde(skip_serializing_if = "std::ops::Not::not")] pub locked: bool, + /// The default style of its kind (`w:default`), whatever its id: localized documents call + /// the default character, table and list styles something other than the English ids. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub default: bool, /// Table style: conditional formatting for header row, banding etc. pub table: Option, } @@ -142,6 +146,10 @@ pub struct StyleSheet { /// the application; nonempty `default_para` is always written after spacing edits. pub default_para_present: bool, pub styles: Vec