Merge remote-tracking branch 'origin/main' into HEAD

; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
This commit is contained in:
Martin Ball committed 2026-11-05 21:10:28 +00:00
commit b341c6af97
101 files changed
+4252 -454

No files matched your search

Generated
+13 -2
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1157,6 +1157,15 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2960,7 +2969,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6880,6 +6889,7 @@ dependencies = [
"log",
"proptest",
"quick-xml 0.41.0",
"serde_json",
"sha1 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
@@ -7084,6 +7094,7 @@ dependencies = [
name = "wordcraft-web"
version = "0.15.1"
dependencies = [
"craft-web-host",
"eframe",
"egui",
"js-sys",
+2 -2
View File
@@ -67,7 +67,7 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+14 -1
View File
@@ -4,7 +4,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-11-04 · **Change:** trivial (progress log: a Wayland clipboard picture is size-checked before it is decoded); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** trivial (progress log: a Wayland clipboard picture is size-checked before it is decoded); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously triLine truncated
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
@@ -139,6 +139,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
- 2026-11-03: Security: a picture pasted from the Wayland clipboard is read encoded under the 200 MB paste cap and refused from its header when it is too large, before anything decodes it; arboard decoded and converted it to RGBA with no limits first (as on Windows, #3623). Readiness unchanged.
- 2026-11-04: OpenDocument files have no display settings any more (gap 19): File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values are gone. WordCraft makes one choice for each case the standard leaves open, the one the default made, and still follows what a file states ([docs/odf-interpretation.md](docs/odf-interpretation.md)).
- 2026-10-31: Dialogs are modal, as in Word (#3859): while one is open, the ribbon, File, the page and the panes of its document window take no clicks, and typing and shortcuts (Ctrl+O…) do nothing until it closes; WordCraft's own Open/Save As window is modal too. A press beside a modal dialog shakes it a little, so it is clear why nothing behind it reacts (Word beeps); with animation effects off, its border flashes instead. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac 16.114.1 (checked on a newer build than the 16.113.4 the parity target names).
@@ -757,6 +759,9 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
Footer page-table height and body wrapping are corrected. Other pagination gaps remain open.
Section page-numbering settings preserve `w:chapStyle` values across the full unsigned-byte range.
MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate.
## Revision history
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
@@ -771,12 +776,17 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. |
| 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
| 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged |
| 2026-11-04 | trivial | Progress log: OpenDocument display settings removed (gap 19) |
| 2026-10-31 | trivial | Progress log: dialogs are modal except the five Word keeps modeless; a press beside one shakes it (#3859) |
| 2026-11-04 | trivial | Progress log: picture brightness, contrast and transparency (#1711, #1766) |
| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) |
| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) |
| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
| 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output |
| 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged |
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
@@ -824,6 +834,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) |
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
| 2026-10-27 | trivial | Progress log: Return on File › Print prints, as Word's default Print button does |
| 2026-10-27 | trivial | PR #4609: Numeric cross-references (#4533): preserve the numbered clause through field updates; adapted from @Alyosha-dot-shop's issue patch, with extra relative-context and cache guards. Synthetic checks only; readiness estimates unchanged. |
| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: Insert Date and Time and DATE/TIME fields use local time (#1707) |
| 2026-10-22 | trivial | Progress log: one native window per open document |
@@ -1207,3 +1218,5 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-27 | trivial | Progress log: AutoCorrect Options with no document open (#4585) |
| 2026-10-28 | trivial | Progress log: RTF metafile pictures are imported (#4259) |
| 2026-10-28 | trivial | Progress log: Chinese and Japanese text counts a word per character (#4547) |
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
+3 -1
View File
@@ -16,6 +16,8 @@ path = "src/main.rs"
[dependencies]
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -29,4 +31,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+65 -107
View File
@@ -1,14 +1,20 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The messages and
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the document's file:
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
//! bytes, so it says `file: false` and a host sends `bytes`.
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
//! copied.
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
//! the file becomes the document's file:
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
//! .docx) is a new file: `true`, as for Save As.
@@ -17,70 +23,57 @@
//! is now that file; the next Save sends its name with `false`.
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
//! app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
//! user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use craft_web_host::web::{FileData, Host, Message};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The app id in the host messages (`wordcraft:ready`).
const APP: &str = "wordcraft";
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "Document.docx";
/// The largest document taken from the host page, refused before it is copied.
const MAX_OPEN_BYTES: u64 = 512 << 20;
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
host: Rc<Host>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the document showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// Hand saved and exported files to the host instead of downloading them, report unsaved
/// changes to it, and hear the files it sends.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Anything else that would download goes to the parent too.
// Anything else that would download goes to the host too.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
// The browser's save picker is never used, so no save finishes later.
services.async_saves = None;
let bridge = self.clone();
let reported: Rc<Cell<Option<bool>>> = Rc::default();
services.on_dirty = Some(Box::new(move |dirty: bool| {
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.set(Some(dirty));
}
}));
// The crate does not repeat a state it already reported.
let host = self.host.clone();
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
@@ -92,78 +85,43 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "wordcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
self.host.post_save(&name, bytes, None, save_as)
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
/// Hear `wordcraft:open` and queue the file like a dropped one.
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&inbox, &ctx, &name, &data);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("wordcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
return;
};
if data.size() > MAX_OPEN_BYTES as f64 {
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
return;
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
ctx.request_repaint();
}
+4 -2
View File
@@ -9,8 +9,10 @@
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
//! comments and tracked changes.
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
//! either host mode.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
+6 -3
View File
@@ -28,9 +28,12 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
// what it saves (`host`). That page guards leaving it, so this one doesn't.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
let bridge = crate::host::Bridge::detect(&canvas);
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
+50 -3
View File
@@ -15,9 +15,25 @@ use eframe::{
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
///
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
// Short-circuits: only x86_64 macOS builds ask the system.
macos && x86_64 && !runs_on_apple_silicon()
}
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
/// the system can't tell.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn runs_on_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
@@ -181,7 +197,8 @@ mod tests {
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
assert!(skips_indirect_validation(true, true));
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
// Apple silicon, Windows and Linux keep wgpu's validation.
for (macos, intel) in [(true, false), (false, true), (false, false)] {
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
@@ -205,6 +222,36 @@ mod tests {
}
}
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
fn host_is_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
/// wgpu's indirect-call validation.
#[test]
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
if !host_is_apple_silicon() {
return;
}
// What main.rs passes from an x86_64 build.
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
let mut options = NativeOptions::default();
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
let before = before.instance_descriptor.flags;
configure_indirect_validation(&mut options, skips_indirect_validation(true, true));
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(create.instance_descriptor.flags, before, "indirect-call validation kept");
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
+2 -1
View File
@@ -486,7 +486,8 @@ fn main() -> eframe::Result {
// FXC, which needs no DLL of ours (#603; see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
// build under Rosetta on Apple silicon keeps them (#5199).
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
load_user_fonts();
+9 -1
View File
@@ -35,7 +35,9 @@ sync_github_name = false
real_name = "Saeed Azarehgar"
[people.vangel76]
sync_github_name = true
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.dkhlapov]
real_name = "Dmitrii Khlapov"
@@ -187,3 +189,9 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
+2
View File
@@ -213,7 +213,9 @@ impl Document {
if !parent.len().is_multiple_of(3) {
return None;
}
crate::work::steps(1);
for ch in parent.chunks(3) {
crate::work::steps(1);
let [b, r, c] = ch else { return None };
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
+2
View File
@@ -609,8 +609,10 @@ impl Document {
let mut at = at.into_iter().peekable();
let mut start = 0usize;
for r in &mut p.runs {
crate::work::steps(1);
let end = start.saturating_add(r.len);
while at.peek().is_some_and(|&o| o < start) {
crate::work::steps(1);
at.next();
}
if at.peek().is_some_and(|&o| o < end) {
+51 -2
View File
@@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
/// Trim spaces and single-character currency signs from either end.
fn trim_currency(s: &str) -> &str {
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
}
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
fn strip_currency(s: &str) -> &str {
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
let mut s = trim_currency(s);
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some((head, word)) = s.rsplit_once(SPACES)
&& (1..=3).contains(&word.chars().count())
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
@@ -137,6 +156,7 @@ mod tests {
assert_eq!(parse_number("1.5", pl), Some(1.5));
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
assert_eq!(parse_number("abc", pl), None);
assert_eq!(parse_number("1,2,3", pl), None);
// From review: ordinals, a leading zero, percentages and an en dash minus.
@@ -174,11 +194,40 @@ mod tests {
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
}
#[test]
fn balboa_numbers() {
let en = Some("en-US");
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
// The sign has no space of its own in Word: touching the number is text.
assert_eq!(parse_number("B/.1,234.50", en), None);
assert_eq!(parse_number("1,234.50B/.", en), None);
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
assert_eq!(parse_number("B/.", en), None);
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
// the middle is not a sign.
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
assert_eq!(parse_number("B/. 1/.5", en), None);
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
}
#[test]
fn english_numbers() {
let en = Some("en-US");
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
assert_eq!(parse_number("$1,250", en), Some(1250.0));
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
assert_eq!(parse_number("1.5", en), Some(1.5));
assert_eq!(parse_number("12", None), Some(12.0));
assert_eq!(parse_number(".5", None), Some(0.5));
+6
View File
@@ -16,6 +16,12 @@ pub type Arg = Vec<MNode>;
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
pub const MAX_DEPTH: usize = 64;
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
pub const MAX_MATRIX_COLS: usize = 64;
/// Most matrix rows kept.
pub const MAX_MATRIX_ROWS: usize = 1000;
/// An equation's structure, plus what's needed to write it back.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Math {
+137 -30
View File
@@ -294,6 +294,14 @@ pub struct Counters {
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
/// level starts at 0).
flags: HashMap<u32, [LevelFlags; 9]>,
/// The last explicitly rendered label and template at each abstract-list level.
labels: HashMap<(u32, u8), CountedLabel>,
}
#[derive(Debug, Clone)]
struct CountedLabel {
label: Option<String>,
levels: Vec<usize>,
}
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
@@ -320,6 +328,7 @@ impl Counters {
let def = level_def(lv)?.clone();
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
if def.format == NumFormat::None {
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
return None;
}
let st = self.state.entry(abs.id).or_insert([0; 9]);
@@ -366,42 +375,101 @@ impl Counters {
if restarts {
*deeper = 0;
*f = LevelFlags::default();
self.labels.remove(&(abs.id, d as u8));
}
}
let vals = *st;
let started = *fl;
if def.format == NumFormat::Bullet {
return Some((def.text.clone(), def));
}
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
let numeric = def.format != NumFormat::Bullet;
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
Some((label, def))
}
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
/// number of its own to show in a reference).
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
let abs = numbering.abstract_of(num)?;
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
}
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
/// `None` for a bullet or "none" level, or a level the list doesn't define.
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
let abs = numbering.abstract_of(num)?;
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
return last.label.clone();
}
// A deeper item can count a level implicitly without rendering a paragraph there.
let def = numbering.level(num, level.min(8))?;
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
return None;
}
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
Some(render_label(numbering, num, def, &vals, &started))
}
/// Levels shown by the last rendered label, or the current definition for an implicit level.
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
if let Some(abs) = numbering.abstract_of(num)
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
{
return last.levels.clone();
}
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
}
}
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
pub fn template_levels(text: &str) -> Vec<usize> {
let mut out = Vec::new();
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
if !out.contains(&k) {
out.push(k);
}
}
}
out
}
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
let level_def = |k: usize| numbering.level(num, k as u8);
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
label
}
/// Longest level text (in characters) expanded into a list label.
@@ -413,6 +481,45 @@ const MAX_LABEL_BYTES: usize = 256;
mod tests {
use super::*;
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
#[test]
fn reference_labels_are_bounded_and_do_not_advance_counters() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
let before = c.values(&n, id);
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
assert_eq!(c.values(&n, id), before);
assert!(label.len() <= 512);
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
assert!(c.started(&n, id).unwrap()[0]);
}
#[test]
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
let mut numbering = Numbering::default();
let first = numbering.add_list(ListKind::Numbered);
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
let mut second = numbering.num(first).unwrap().clone();
second.id = first + 1;
second.level_overrides = vec![
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
];
let second_id = second.id;
numbering.nums.push(second);
let mut counters = Counters::default();
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
}
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
+3 -1
View File
@@ -26,7 +26,9 @@ pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
+76 -2
View File
@@ -26,6 +26,40 @@ impl Align {
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextAlignment {
Auto,
Top,
Center,
Baseline,
Bottom,
}
impl TextAlignment {
pub fn ooxml(self) -> &'static str {
match self {
TextAlignment::Auto => "auto",
TextAlignment::Top => "top",
TextAlignment::Center => "center",
TextAlignment::Baseline => "baseline",
TextAlignment::Bottom => "bottom",
}
}
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
match s {
"auto" => Some(TextAlignment::Auto),
"top" => Some(TextAlignment::Top),
"center" => Some(TextAlignment::Center),
"baseline" => Some(TextAlignment::Baseline),
"bottom" => Some(TextAlignment::Bottom),
_ => None,
}
}
}
/// Line spacing rule.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
@@ -107,6 +141,18 @@ pub enum BorderStyle {
Triple,
DotDash,
Wave,
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
/// line of the border's width.
ThinThickSmallGap,
ThickThinSmallGap,
ThinThickThinSmallGap,
ThinThickMediumGap,
ThickThinMediumGap,
ThinThickThinMediumGap,
ThinThickLargeGap,
ThickThinLargeGap,
ThinThickThinLargeGap,
}
impl BorderStyle {
@@ -121,6 +167,15 @@ impl BorderStyle {
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
@@ -133,6 +188,15 @@ impl BorderStyle {
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
_ => BorderStyle::Single,
}
}
@@ -241,6 +305,9 @@ pub struct TextFrame {
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_lock: Option<bool>,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
@@ -295,6 +362,9 @@ pub struct ParaProps {
/// outside ones, so they swap sides on even (left-hand) pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_indents: Option<bool>,
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub adjust_right_indent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_next: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -368,6 +438,10 @@ pub struct ParaProps {
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_alignment: Option<TextAlignment>,
}
impl ParaProps {
@@ -379,7 +453,7 @@ impl ParaProps {
fn overlay_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
}
fn overlay_pagination(&mut self, patch: &ParaProps) {
@@ -388,7 +462,7 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
+8
View File
@@ -153,6 +153,10 @@ impl ResolvedChar {
}
/// Concrete paragraph formatting.
fn default_adjust_right_indent() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedPara {
@@ -173,6 +177,9 @@ pub struct ResolvedPara {
pub contextual_spacing: bool,
/// Left/right indents are inside/outside: they swap on even pages.
pub mirror_indents: bool,
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(default = "default_adjust_right_indent")]
pub adjust_right_indent: bool,
pub keep_next: bool,
pub keep_lines: bool,
pub page_break_before: bool,
@@ -264,6 +271,7 @@ impl ResolvedPara {
},
contextual_spacing: p.contextual_spacing.unwrap_or(false),
mirror_indents: p.mirror_indents.unwrap_or(false),
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
keep_next: p.keep_next.unwrap_or(false),
keep_lines: p.keep_lines.unwrap_or(false),
page_break_before: p.page_break_before.unwrap_or(false),
+50
View File
@@ -115,6 +115,40 @@ pub enum NumFormat {
DollarText,
}
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChapterSep {
Hyphen,
Period,
Colon,
EmDash,
EnDash,
}
impl ChapterSep {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"hyphen" => Some(Self::Hyphen),
"period" => Some(Self::Period),
"colon" => Some(Self::Colon),
"emDash" => Some(Self::EmDash),
"enDash" => Some(Self::EnDash),
_ => None,
}
}
pub const fn ooxml(self) -> &'static str {
match self {
Self::Hyphen => "hyphen",
Self::Period => "period",
Self::Colon => "colon",
Self::EmDash => "emDash",
Self::EnDash => "enDash",
}
}
}
impl NumFormat {
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
/// binary .doc number format codes up to 0x27).
@@ -749,6 +783,9 @@ pub struct SectionProps {
pub title_page: bool,
pub page_num_start: Option<u32>,
pub page_num_format: NumFormat,
/// Chapter heading level and separator in `w:pgNumType`.
pub chapter_style: Option<u8>,
pub chapter_sep: Option<ChapterSep>,
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
/// format), so saving writes it back rather than dropping it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
@@ -779,6 +816,13 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// First-page and other-page printer trays (`w:paperSrc`).
pub paper_src_first: Option<u16>,
pub paper_src_other: Option<u16>,
/// Section-specific suppression of endnotes (`w:noEndnote`).
pub no_endnote: Option<bool>,
/// Independent footnote column count (`w15:footnoteColumns`).
pub footnote_columns: Option<u32>,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
@@ -809,6 +853,8 @@ impl Default for SectionProps {
title_page: false,
page_num_start: None,
page_num_format: NumFormat::Decimal,
chapter_style: None,
chapter_sep: None,
page_num_set: false,
line_numbers: None,
valign: PageVAlign::Top,
@@ -821,6 +867,10 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
paper_src_first: None,
paper_src_other: None,
no_endnote: None,
footnote_columns: None,
balance_columns: None,
}
}
+35 -1
View File
@@ -12,6 +12,8 @@
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
@@ -198,11 +200,14 @@ pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
/// Fill of the item's shape in place of the style's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level }
SmartArtItem { text: text.into(), level, color: None }
}
}
@@ -257,6 +262,35 @@ impl SmartArtSpec {
}
}
/// Whether any item has its own colour.
pub fn has_custom_colors(&self) -> bool {
self.items.iter().any(|it| it.color.is_some())
}
/// Give every item the style's colour again.
pub fn clear_custom_colors(&mut self) {
for it in &mut self.items {
it.color = None;
}
}
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
/// its own (it is text inside another item's shape).
pub fn style_accent(&self, i: usize) -> Option<usize> {
let it = self.items.get(i)?;
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
match self.layout {
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
SmartArtLayout::Radial => {
let parents = self.parents();
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
}
_ => (it.level == 0).then(|| self.colors.accent(tops())),
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
+25
View File
@@ -966,6 +966,31 @@ fn table_styles() -> Vec<Style> {
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::ResolvedPara;
#[test]
fn adjust_right_indent_inherits_and_can_be_overridden() {
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
assert!(resolved.adjust_right_indent);
let mut older_json = serde_json::to_value(&resolved).unwrap();
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
let mut s = StyleSheet::empty();
s.styles.push(Style {
id: "Normal".into(),
name: "Normal".into(),
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
..Default::default()
});
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
assert_eq!(json["adjustRightIndent"], false);
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
}
#[test]
fn builtins_present() {
+9 -26
View File
@@ -102,18 +102,6 @@ fn dense(n: usize) -> Document {
d
}
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
// evidence; the semantic assertions still run on those platforms.
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
#[test]
fn conversion_work_scales_with_reference_and_formatting_runs() {
let (small, big) = (dense(2_000), dense(16_000));
@@ -128,18 +116,13 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
};
convert(&small);
convert(&big);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
let b = cpu_cost(|| convert(&big));
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
}
// The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x
// the references and runs when they are walked together, about 64x when each run rescans the
// references.
let s1 = crate::work::count(|| convert(&small));
let s8 = crate::work::count(|| convert(&big));
let ratio = s8 as f64 / s1.max(1) as f64;
eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs");
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})");
}
+1
View File
@@ -51,6 +51,7 @@ pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, dept
return;
}
for (i, b) in bl.iter().enumerate() {
crate::work::steps(1);
prefix.push(i as u32);
match &**b {
Block::Para(_) => out.push(Path(prefix.clone())),
+9 -7
View File
@@ -1,8 +1,9 @@
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
//! count is nothing.
//! A count of the characters a paragraph's object lookups walk, the blocks
//! [`crate::Document::para_paths`] visits, and each block lookup by path with the table levels it
//! descends, for the linear-time tests of the crates that use them: an input 8x the size must cost
//! about 8x the steps, which a busy machine can't skew the way it skews a timing. Counted only in
//! tests (this crate's, and other crates' through the `test-support` feature, which they turn on
//! as a dev-dependency); in the app the count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
@@ -10,9 +11,10 @@ thread_local! {
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests).
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
/// paragraph's text themselves (Markdown and HTML comment export, for one).
#[inline(always)]
pub(crate) fn steps(n: usize) {
pub fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
+106 -19
View File
@@ -51,6 +51,14 @@ const MAX_PARTS: usize = 50_000;
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// Most paragraph and cell marks across every story walk of one document. The character
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
/// file of nothing but marks would grow memory about a thousand times faster than its size.
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
/// rows, each a cell mark plus a row mark) is about 920,000.
const MAX_PARAGRAPHS: usize = 2_000_000;
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
@@ -195,6 +203,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
read_limited(bytes, password, MAX_PARAGRAPHS)
}
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
/// file of [`MAX_PARAGRAPHS`] marks.
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
let mut comp = open_container(bytes)?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
@@ -270,6 +284,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let work = Work::new(MAX_WORK);
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
let ctx = WalkCtx {
word: &word,
@@ -285,6 +300,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
drawn: &[],
sect_ends: &[],
budget: &budget,
paragraphs: &paragraphs,
work: &work,
};
@@ -304,7 +320,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
continue;
}
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
}
}
@@ -329,7 +345,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
0 | 1 | 4 => PartKind::Header,
_ => PartKind::Footer,
};
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() {
let id = doc.add_part(kind, blocks);
hf_ids.push((si, k, id));
@@ -342,7 +358,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Footnote, blocks);
note_links.push((cp, NoteKind::Footnote, id));
@@ -350,7 +366,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
}
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Endnote, blocks);
note_links.push((cp, NoteKind::Endnote, id));
@@ -393,6 +409,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.last_section = l;
}
if paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
doc.media.extend(pics.media);
doc.body = table::assemble(paras);
doc.ensure_nonempty();
@@ -400,6 +419,20 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
Ok(doc)
}
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
}
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
/// its partial list is assembled, so a flood does not build blocks it will throw away.
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
let paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
Ok(table::assemble(paras))
}
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
@@ -433,10 +466,35 @@ struct WalkCtx<'a> {
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
paragraphs: &'a ParaBudget,
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
work: &'a Work,
}
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
/// the end of them (the read then fails).
struct ParaBudget {
left: std::cell::Cell<usize>,
exceeded: std::cell::Cell<bool>,
}
impl ParaBudget {
/// Spends one mark; false (and the overrun noted) when none are left.
fn spend(&self) -> bool {
match self.left.get().checked_sub(1) {
Some(left) => {
self.left.set(left);
true
}
None => {
self.exceeded.set(true);
false
}
}
}
}
/// State while assembling one paragraph from per-CP formatting.
#[derive(Default)]
struct ParaBuild {
@@ -527,6 +585,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
if !ctx.paragraphs.spend() {
break;
}
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
// querying at the mark's own FC finds that paragraph's properties.
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
@@ -536,6 +597,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
}
let mut row = table::decode(papx);
row.cell_mark = c == '\u{7}';
// Only table marks keep their table context; a plain paragraph would carry
// a `RowInfo` (about 300 bytes) it never uses.
let row = row.is_table().then(|| Box::new(row));
let mut ilvl = 0u8;
let mut ilfo = 0i32;
for prl in sprm::iter(papx) {
@@ -589,7 +653,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
&& f.depth == 1
&& f.sep.is_none()
{
f.sep = Some(Vec::new());
f.sep = Some(FieldResult::default());
}
cp = cp.saturating_add(1);
continue;
@@ -669,7 +733,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
out.push(ParaOut {
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
end_cp: cp_end,
row: table::RowInfo::default(),
row: None,
});
}
out
@@ -680,7 +744,28 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
struct FieldBuild {
depth: u32,
instr: String,
sep: Option<Vec<(char, CharProps)>>,
sep: Option<FieldResult>,
}
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
/// only when the formatting changes, so a result of millions of equally formatted characters
/// costs one `CharProps`, not one per character (365 bytes each).
#[derive(Default)]
struct FieldResult {
text: String,
/// (byte length in `text`, formatting) in order; adjacent runs differ.
runs: Vec<(usize, CharProps)>,
}
impl FieldResult {
fn push(&mut self, c: char, props: CharProps) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some((n, last)) if *last == props => *n += len,
_ => self.runs.push((len, props)),
}
self.text.push(c);
}
}
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
@@ -705,24 +790,26 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
let result = f.sep.unwrap_or_default();
if let Some(target) = hyperlink_target(&instr) {
let cost = target.len() as u64;
let mut run: Option<CharProps> = None;
for (c, props) in result {
if run.as_ref() == Some(&props) {
pb.extend(c);
continue;
}
let mut linked = props.clone();
let mut chars = result.text.chars();
for (len, props) in result.runs {
let mut linked = props;
if work.charge(cost, Cost::LinkTarget) {
linked.link = Some(target.clone());
}
pb.push(c, linked);
run = Some(props);
let mut left = len;
let Some(first) = chars.next() else { break };
pb.push(first, linked);
left = left.saturating_sub(first.len_utf8());
while left > 0 {
let Some(c) = chars.next() else { break };
pb.extend(c);
left = left.saturating_sub(c.len_utf8());
}
}
return;
}
let result_text: String = result.iter().map(|(c, _)| *c).collect();
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
}
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
@@ -806,7 +893,7 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
Some(f) => match f.sep.as_mut() {
// Inside a field, characters are recorded for the field object instead of shown;
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
Some(result) => result.push((c, props)),
Some(result) => result.push(c, props),
None => f.instr.push(c),
},
None => pb.push(c, props),
+9 -6
View File
@@ -83,6 +83,11 @@ pub(crate) struct RowInfo {
/// Read the table context out of a paragraph mark's PAPX grpprl.
impl RowInfo {
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
pub(crate) fn is_table(&self) -> bool {
self.in_table || self.ttp || self.cell_mark
}
/// The row height from `sprmTDyaRowHeight`, for tests.
#[cfg(test)]
pub(crate) fn height(&self) -> Option<f32> {
@@ -263,8 +268,8 @@ pub(crate) struct ParaOut {
pub(crate) para: Paragraph,
/// CP just past the terminating mark.
pub(crate) end_cp: u32,
/// Table context from the mark's PAPX (empty default = not in a table).
pub(crate) row: RowInfo,
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
pub(crate) row: Option<Box<RowInfo>>,
}
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
@@ -369,9 +374,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
for p in paras {
let info = p.row;
let is_table = info.in_table || info.ttp || info.cell_mark;
if !is_table {
let Some(info) = p.row else {
if open {
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
grid.clear();
@@ -386,7 +389,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
out.push(para_block(p.para));
continue;
}
};
if !open {
open = true;
}
+136 -3
View File
@@ -116,6 +116,12 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
/// A document whose main text is the given pieces (extra subdocument text may follow).
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
}
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
let mut word = vec![0u8; TEXT_FC as usize];
@@ -128,7 +134,13 @@ fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
clx.extend_from_slice(&plc);
let mut table = vec![0u8; CLX_AT as usize];
table.extend_from_slice(&clx);
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
for (pair, bytes) in table_data {
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
table.extend_from_slice(bytes);
}
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
let spec = FibSpec { ccp_text, pairs, ..spec };
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
@@ -247,6 +259,77 @@ fn empty_paragraphs_kept() {
assert_eq!(body_texts(&doc), ["a", "", "b"]);
}
/// A document of `n` copies of `mark` as one compressed piece.
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
}
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
/// until the machine runs out. A document exactly at the cap still opens.
#[test]
fn a_flood_of_paragraph_marks_is_refused() {
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 1000);
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
#[test]
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
let doc_of = |body: usize, note: usize| {
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
let mut refs = cps(&[0, 1]);
refs.extend_from_slice(&[0, 0]); // the one reference's data
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
text_doc_with(
&[Piece { compressed: true, text: "\r".repeat(body) }],
&[Piece { compressed: true, text: "\r".repeat(note) }],
spec,
&[(2, &refs), (3, &txt)],
)
};
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 600);
assert_eq!(at_cap.parts.len(), 1);
let over = super::read_limited(&doc_of(600, 401), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
let over = super::read_limited(&doc_of(601, 400), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
#[test]
fn a_flood_of_cell_marks_is_refused() {
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
/// every story of the document).
#[test]
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
assert!(budget.spend() && budget.spend());
assert!(!budget.exceeded.get());
assert!(!budget.spend());
assert!(budget.exceeded.get());
}
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
#[test]
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
let doc = read(&marks_doc('\r', 10)).expect("opens");
assert_eq!(doc.body.len(), 10);
}
#[test]
fn empty_document_gets_a_paragraph() {
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
@@ -2660,7 +2743,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
assert_eq!(linked.first().map(|r| r.len), Some(n));
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
// what the copies charge to the work budget, which a busy machine can't skew.
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
let mut result = super::FieldResult::default();
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
let work = super::Work::new(u64::MAX);
super::finish_field(&mut super::ParaBuild::default(), f, &work);
@@ -2673,7 +2757,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
let mut result = super::FieldResult::default();
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
let mut pb = super::ParaBuild::default();
let budget = super::Work::new(10_000);
@@ -2747,3 +2832,51 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
assert_eq!(work.left.get(), 1);
assert_eq!(work.dropped(), [super::Cost::Formatting]);
}
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
/// (365 bytes each: 4 million result characters took 1.4 GB).
#[test]
fn a_field_result_stores_one_props_per_run() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut r = super::FieldResult::default();
for _ in 0..200_000 {
r.push('x', CharProps::default());
}
assert_eq!(r.runs.len(), 1);
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
r.push('\u{e9}', bold.clone());
r.push('y', bold);
r.push('z', CharProps::default());
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
assert_eq!(r.text.len(), 200_004);
}
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
/// links all of it; a plain field keeps its result text whole.
#[test]
fn field_results_keep_their_text_and_runs() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
for (c, p) in
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
{
result.push(c, p);
}
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
let mut pb = super::ParaBuild::default();
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
assert!(
p.objects
.iter()
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
"{:?}",
p.objects
);
}
+1
View File
@@ -32,6 +32,7 @@ getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
serde_json = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+23 -2
View File
@@ -5,8 +5,8 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
@@ -33,6 +33,7 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
}
@@ -333,6 +334,7 @@ impl PropCtx {
"w:widowControl" => p.widow_control = Some(on_off(k)),
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
@@ -343,6 +345,7 @@ impl PropCtx {
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
"w:framePr" => {
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
@@ -762,8 +765,26 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
if let Some(f) = k.attr("w:fmt") {
s.page_num_format = NumFormat::from_ooxml(f);
}
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
}
"w:paperSrc" => {
let tray = |name| {
let value = k.attr(name)?.trim();
let digits = value.strip_prefix('+').unwrap_or(value);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
};
s.paper_src_first = tray("w:first");
s.paper_src_other = tray("w:other");
}
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
"w15:footnoteColumns" => {
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
}
"w:lnNumType" => {
let mut l = LineNumbering::default();
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
+83 -19
View File
@@ -86,6 +86,8 @@ enum Paint {
None,
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
Accent(usize, Option<u32>),
/// A colour of its own, lightened to `tint` when set.
Rgb(Rgb, Option<u32>),
/// The theme's light background colour.
Light,
}
@@ -150,6 +152,22 @@ impl Sh {
}
}
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
/// keep the theme's background colour so adjacent nodes remain separated.
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
for p in [&mut self.fill, &mut self.line] {
if let Paint::Accent(_, tint) = *p {
*p = Paint::Rgb(c, tint);
}
}
}
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
self.recolor(spec, i);
self
}
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
self.text = text;
self
@@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k - 1) {
a.recolor(spec, i);
}
out.push(a);
}
for (k, &i) in tops.iter().enumerate() {
let b = [k as f32 * (nw + gap), y, nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
a.rot = (mid + 90.0).rem_euclid(360.0);
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k) {
a.recolor(spec, i);
}
out.push(a);
}
}
@@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
let b = [x - d / 2.0, y - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let (px, pb) = (cx(i), top(i) + nh);
let ct = ks.first().map_or(pb, |&k| top(k));
let mid = (pb + ct) / 2.0;
out.push(Sh::line(px, pb, px, mid, accent));
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
if hi - lo > 0.5 {
out.push(Sh::line(lo, mid, hi, mid, accent));
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
}
for x in xs {
out.push(Sh::line(x, mid, x, ct, accent));
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
}
}
for i in 0..n {
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
}
out
}
@@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
};
out.push(sh.with_text(paras(spec, i, true)));
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
if n == 0 {
let d = m * 0.5;
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
return out;
}
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
@@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let at = |k: usize| polar(cx, cy, r, angle(k, n));
for k in 0..n {
let (x, y) = at(k);
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
}
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
for (k, &i) in spokes.iter().enumerate() {
let (x, y) = at(k);
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (r, c) = (k / cols, k % cols);
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
}
out
}
@@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
(x, y, deg)
};
let mut out = Vec::new();
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
s.alpha = Some(60_000);
s.line = Paint::None;
out.push(s);
out.push(s.item(spec, i));
}
// Every outline over every fill, so each circle shows whole.
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::None;
s.line = Paint::Accent(spec.colors.accent(j), None);
s.line_w = 1.5;
out.push(s);
out.push(s.item(spec, i));
}
for (j, &i) in tops.iter().enumerate() {
let (x, y, deg) = place(j);
@@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
w.empty("a:schemeClr", &[("val", "lt1")]);
w.close("a:solidFill");
}
Paint::Rgb(c, tint) => {
w.open("a:solidFill", &[]);
let val = c.hex();
if tint.is_none() && alpha.is_none() {
w.empty("a:srgbClr", &[("val", &val)]);
} else {
w.open("a:srgbClr", &[("val", &val)]);
if let Some(t) = tint {
w.empty("a:tint", &[("val", &t.to_string())]);
}
if let Some(a) = alpha {
w.empty("a:alpha", &[("val", &a.to_string())]);
}
w.close("a:srgbClr");
}
w.close("a:solidFill");
}
Paint::Accent(n, tint) => {
w.open("a:solidFill", &[]);
let val = format!("accent{}", n.clamp(1, 6));
@@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
let (node, cxn, par, sib) = item_ids(i);
w.open("dgm:pt", &[("modelId", &node)]);
w.empty("dgm:prSet", &[]);
w.empty("dgm:spPr", &[]);
match it.color {
Some(c) => {
w.open("dgm:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("dgm:spPr");
}
None => w.empty("dgm:spPr", &[]),
}
w.open("dgm:t", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
@@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
.zip(&levels)
.map(|(p, l)| {
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
Some(SmartArtItem { text, level: (*l)? })
let color =
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
Some(SmartArtItem { text, level: (*l)?, color })
})
.collect::<Option<Vec<_>>>()?;
let spec = SmartArtSpec { layout, items, colors }.sanitized();
@@ -1138,6 +1190,18 @@ mod tests {
}
}
#[test]
fn an_items_own_colour_is_saved_drawn_and_read_back() {
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
let drawing = drawing_xml(&spec, 400.0, 250.0);
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
assert!(!plain.contains("srgbClr"));
}
#[test]
fn a_diagram_with_more_than_the_model_stays_read_only() {
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
+6 -2
View File
@@ -1,7 +1,8 @@
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
ScriptKind, parse_linear,
};
use wordcraft_doc::props::Rgb;
@@ -181,7 +182,10 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
let MNode::Matrix { rows, col_jc } = n else { return };
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
// caps rather than written as rows × widest cells (#3643).
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
w.open("m:m", &[]);
w.open("m:mPr", &[]);
w.open("m:mcs", &[]);
+7
View File
@@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
if let Some(l) = p.outline_level {
w.val("w:outlineLvl", &n(l.min(9) as i64));
}
@@ -323,6 +326,9 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
a.push(("w:vSpace", &vs));
}
a.push(("w:wrap", &f.wrap));
if let Some(anchor_lock) = f.anchor_lock {
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
}
a.push(("w:vAnchor", &f.v_anchor));
a.push(("w:hAnchor", &f.h_anchor));
match &f.x_align {
@@ -370,6 +376,7 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
toggle(w, "w:autoSpaceDE", p.auto_space_de);
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
toggle(w, "w:snapToGrid", p.snap_to_grid);
}
+45 -8
View File
@@ -87,6 +87,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
("w:gutter", &twips(s.gutter.max(0.0))),
],
);
paper_source(w, s);
if let Some(b) = &s.page_borders {
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
}
@@ -107,14 +108,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:lnNumType", &refs);
}
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(st) = s.page_num_start {
a.push(("w:start", st.to_string()));
}
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
page_number_type(w, s);
let c = &s.columns;
let count = c.count.clamp(1, 45);
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
@@ -134,6 +128,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:cols", &refs);
}
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
}
if let Some(on) = s.form_prot {
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
}
@@ -143,6 +140,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
}
if let Some(on) = s.no_endnote {
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
}
if s.title_page {
w.empty("w:titlePg", &[]);
}
@@ -167,6 +167,43 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
}
}
fn paper_source(w: &mut W, s: &SectionProps) {
let mut attrs = Vec::new();
if let Some(first) = s.paper_src_first {
attrs.push(("w:first", first.to_string()));
}
if let Some(other) = s.paper_src_other {
attrs.push(("w:other", other.to_string()));
}
if !attrs.is_empty() {
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:paperSrc", &refs);
}
}
fn page_number_type(w: &mut W, s: &SectionProps) {
if !(s.page_num_set
|| s.page_num_start.is_some()
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|| s.chapter_style.is_some()
|| s.chapter_sep.is_some())
{
return;
}
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(start) = s.page_num_start {
attrs.push(("w:start", start.to_string()));
}
if let Some(style) = s.chapter_style {
attrs.push(("w:chapStyle", style.to_string()));
}
if let Some(separator) = s.chapter_sep {
attrs.push(("w:chapSep", separator.ooxml().to_string()));
}
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
+76
View File
@@ -0,0 +1,76 @@
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::BorderStyle;
const LINES: [(&str, BorderStyle); 9] = [
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
];
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn side(name: &str, val: &str) -> String {
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
}
#[test]
fn compound_paragraph_borders_are_written_back_as_read() {
let body: String = LINES
.iter()
.map(|(val, _)| {
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
})
.collect();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<BorderStyle>> =
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
for (val, _) in LINES {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
}
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
}
#[test]
fn compound_cell_and_page_borders_are_written_back_as_read() {
let body = format!(
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
side("top", "thickThinLargeGap"),
side("left", "thinThickSmallGap"),
side("right", "thinThickThinMediumGap")
);
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
}
}
+29
View File
@@ -80,3 +80,32 @@ fn automatic_spacing_reads_and_round_trips() {
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(props(&again)[..3], p[..3]);
}
#[test]
fn adjust_right_indent_is_preserved() {
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/><w:snapToGrid w:val="0"/><w:spacing w:after="120"/><w:ind w:right="120"/></w:pPr><w:r><w:t>false</w:t></w:r></w:p>
<w:p><w:pPr><w:adjustRightInd w:val="1"/></w:pPr><w:r><w:t>true</w:t></w:r></w:p>
<w:p><w:r><w:t>absent</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body)).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
let pprs = xml.split("<w:pPr>").skip(1).filter_map(|rest| rest.split("</w:pPr>").next()).collect::<Vec<_>>();
let first = pprs.first().expect("paragraph properties must be present");
for &tag in &tags {
assert!(first.contains(tag), "{tag} missing: {xml}");
}
for ppr in pprs {
let order = tags.iter().filter_map(|tag| ppr.find(*tag)).collect::<Vec<_>>();
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "paragraph properties are out of schema order: {xml}");
}
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
}
}
+40
View File
@@ -247,6 +247,46 @@ fn frame_zero_side_is_automatic() {
}
}
#[test]
fn locked_frame_anchor_survives_two_saves() {
for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] {
let mut document = read_body(&format!(r#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
let paragraph = paras(&document)[0];
assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else {
panic!("first block is the framed paragraph");
};
let frame = paragraph.props.frame.as_mut().unwrap();
frame.x = 36.0;
frame.y = 72.0;
frame.w = Some(108.0);
}
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
for pass in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = text(&unzip(&bytes), "word/document.xml");
let expected_xml = match expected {
Some(true) => "w:anchorLock=\"1\"",
Some(false) => "w:anchorLock=\"0\"",
None => "w:anchorLock=",
};
assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1);
document = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
}
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
+3
View File
@@ -1,6 +1,7 @@
mod alt_chunks;
mod bidi;
mod bidi_runs;
mod border_lines;
mod cell_margin_inheritance;
mod char_units;
mod citations;
@@ -28,10 +29,12 @@ mod roundtrip;
mod row_property_exceptions;
mod run_props;
mod section_form_protection;
mod section_metadata_preservation;
mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod undefined_style;
+4 -1
View File
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -176,6 +176,7 @@ fn every_para_prop_round_trips() {
line_spacing: Some(LineSpacing::Multiple(1.5)),
contextual_spacing: Some(true),
mirror_indents: Some(true),
adjust_right_indent: Some(false),
keep_next: Some(true),
keep_lines: Some(false),
page_break_before: Some(true),
@@ -209,6 +210,7 @@ fn every_para_prop_round_trips() {
h_anchor: "margin".into(),
v_anchor: "text".into(),
wrap: "around".into(),
anchor_lock: Some(true),
h_space: 9.0,
v_space: 0.0,
}),
@@ -221,6 +223,7 @@ fn every_para_prop_round_trips() {
num_change: None,
fmt_change: None,
snap_to_grid: Some(false),
text_alignment: Some(TextAlignment::Top),
};
let variants = [
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
+24
View File
@@ -29,6 +29,30 @@ fn part(bytes: &[u8], name: &str) -> String {
xml
}
#[test]
fn paragraph_adjust_right_indent_inherits_from_style() {
let styles =
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:adjustRightInd/></w:pPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/></w:pPr><w:r><w:t>off</w:t></w:r></w:p>
<w:p><w:r><w:t>inherited</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body, styles)).unwrap();
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
assert!(!d.styles.resolve_para(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[1].props).adjust_right_indent);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
assert!(part(&out, "word/document.xml").contains(r#"<w:adjustRightInd w:val="0""#));
assert!(part(&out, "word/styles.xml").contains("<w:adjustRightInd/>"));
d = wordcraft_docx::read(&out).unwrap();
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
assert!(!d.styles.resolve_para(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[1].props).adjust_right_indent);
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
}
}
#[test]
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
let styles = r#"
@@ -0,0 +1,66 @@
//! Section printer, note and chapter-number metadata survives DOCX and native JSON saves.
use std::io::{Read, Write};
fn docx(body: &str) -> Vec<u8> {
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w15"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn section_metadata_survives_native_json_and_two_docx_save_reopen_cycles() {
let input = docx(
r#"<w:p><w:pPr><w:sectPr><w:paperSrc w:first=" +261 " w:other=" 260 "/><w:noEndnote/><w15:footnoteColumns w:val="2"/><w:pgNumType w:fmt="decimal" w:chapStyle="1" w:chapSep="hyphen"/></w:sectPr></w:pPr><w:r><w:t>First section</w:t></w:r></w:p><w:p><w:r><w:t>Second section</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="12" w:other="34"/><w:noEndnote w:val="0"/><w15:footnoteColumns w:val="3"/><w:pgNumType w:fmt="upperRoman" w:chapStyle="10" w:chapSep="colon"/></w:sectPr>"#,
);
let mut document = wordcraft_docx::read(&input).unwrap();
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
for cycle in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = document_xml(&bytes);
assert!(xml.contains(r#"<w:paperSrc w:first="261" w:other="260"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w:paperSrc w:first="12" w:other="34"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains("<w:noEndnote/>"), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w:noEndnote w:val="0"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w15:footnoteColumns w:val="2"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w15:footnoteColumns w:val="3"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"w:chapStyle="1" w:chapSep="hyphen""#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"w:chapStyle="10" w:chapSep="colon""#), "cycle {cycle}: {xml}");
assert!(xml.contains("xmlns:w15=") && xml.contains("mc:Ignorable=\"w14 w15 wp14\""), "cycle {cycle}: {xml}");
document = wordcraft_docx::read(&bytes).unwrap();
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
}
}
#[test]
fn invalid_section_metadata_is_ignored_and_missing_values_stay_missing() {
let bytes = docx(
r#"<w:p><w:r><w:t>Text</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="65536" w:other="-1"/><w15:footnoteColumns w:val="0"/><w:pgNumType w:chapStyle="256" w:chapSep="invalid"/></w:sectPr>"#,
);
let mut d = wordcraft_docx::read(&bytes).unwrap();
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(!xml.contains("paperSrc"), "{xml}");
assert!(!xml.contains("footnoteColumns"), "{xml}");
assert!(!xml.contains("chapStyle") && !xml.contains("chapSep"), "{xml}");
assert!(!xml.contains("noEndnote"), "{xml}");
d.last_section.footnote_columns = Some(0);
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(!xml.contains("footnoteColumns"), "{xml}");
}
+64
View File
@@ -0,0 +1,64 @@
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
//! moves every line after. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::TextAlignment;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn text_alignment_is_written_back_as_read() {
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let body = [
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
para("", "G"),
]
.concat();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
let want = vec![
Some(TextAlignment::Top),
Some(TextAlignment::Center),
Some(TextAlignment::Baseline),
Some(TextAlignment::Bottom),
Some(TextAlignment::Auto),
None,
None,
];
assert_eq!(got, want);
let out = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&out);
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
for v in ["center", "baseline", "bottom", "auto"] {
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
}
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
}
+23
View File
@@ -0,0 +1,23 @@
//! A ragged equation matrix doesn't make the saved `document.xml` grow with rows × widest row
//! (#3643): one wide row plus many empty rows used to be padded out to a full grid.
use wordcraft_doc::math::{MNode, MRun, Math};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Paragraph, para_block};
#[test]
fn ragged_matrix_save_stays_small() {
let n = 5000;
let mut rows = vec![vec![vec![MNode::Run(MRun::new("a"))]; n]];
rows.extend(std::iter::repeat_with(Vec::new).take(n));
let math = Math { nodes: vec![MNode::Matrix { rows, col_jc: Vec::new() }], ..Default::default() };
let mut p = Paragraph::new();
p.insert_object(0, InlineObject::Equation { linear: String::new(), display: true, math }, &CharProps::default()).unwrap();
let mut doc = Document::new();
doc.body = vec![para_block(p)];
let bytes = wordcraft_docx::write(&doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let size = zip.by_name("word/document.xml").unwrap().size();
assert!(size < 1_000_000, "document.xml is {size} bytes");
}
+1
View File
@@ -52,6 +52,7 @@ chrono = { workspace = true, features = ["clock"] }
libc = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
encoding_rs = { workspace = true }
zip = { workspace = true }
cfb = { workspace = true }
+9 -3
View File
@@ -26,6 +26,9 @@ use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise}
use super::pos_json;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
mod page_objects;
use page_objects::PageObjects;
/// What dragging on the page does (Draw tab).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -325,12 +328,13 @@ impl PlacedInk {
/// turn and geometry on the page.
pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<PlacedInk> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut objects = PageObjects::new(&s.doc);
pg.items
.iter()
.filter_map(|it| match it {
Placed::Object { rect, spin, story, path, off, .. } => {
let pos = Pos { story: *story, path: path.clone(), off: *off };
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
match objects.get(&pos) {
Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => {
Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width })
}
@@ -577,10 +581,11 @@ fn lasso(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("a lasso loop needs at least three points".into()));
}
let mut items = Vec::new();
let mut objects = PageObjects::new(&s.doc);
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(obj) = objects.get(&pos) else { continue };
let picked = match obj.ink() {
Some(_) if s.view.hide_ink => false,
Some(f) => {
@@ -771,13 +776,14 @@ fn near(a: Rect, b: Rect) -> bool {
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut out = Vec::new();
let mut objects = PageObjects::new(&s.doc);
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
if !spin.is_identity() || !keep(&pos, rect) {
continue;
}
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = objects.get(&pos) else { continue };
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
@@ -0,0 +1,49 @@
//! Per-call object lookups for drawing-order page walks, which can revisit a paragraph's
//! offsets out of text order. Scan only as far as the furthest requested object, once.
use std::collections::HashMap;
use wordcraft_doc::para::OBJ;
use wordcraft_doc::{Document, InlineObject, Path, Pos, StoryRef};
#[derive(Default)]
struct ParagraphObjects<'a> {
scanned: usize,
ordinal: usize,
objects: Vec<(usize, &'a InlineObject)>,
}
pub(super) struct PageObjects<'a> {
doc: &'a Document,
paragraphs: HashMap<(StoryRef, Path), ParagraphObjects<'a>>,
}
impl<'a> PageObjects<'a> {
pub(super) fn new(doc: &'a Document) -> Self {
Self { doc, paragraphs: HashMap::new() }
}
pub(super) fn get(&mut self, pos: &Pos) -> Option<&'a InlineObject> {
let p = self.doc.para_at(pos)?;
if !p.text.get(pos.off..)?.starts_with(OBJ) {
return None;
}
let end = pos.off.checked_add(OBJ.len_utf8())?;
let cached = self.paragraphs.entry((pos.story, pos.path.clone())).or_default();
if end > cached.scanned {
// Start after the last scanned marker, not at the paragraph's start. In particular,
// a lone object near the start never scans a long text suffix it doesn't need.
for (off, c) in p.text.get(cached.scanned..end)?.char_indices() {
wordcraft_doc::work::steps(1);
if c == OBJ {
if let Some(object) = p.objects.get(cached.ordinal) {
cached.objects.push((cached.scanned + off, object));
}
cached.ordinal += 1;
}
}
cached.scanned = end;
}
let i = cached.objects.binary_search_by_key(&pos.off, |(off, _)| *off).ok()?;
cached.objects.get(i).map(|(_, object)| *object)
}
}
+2
View File
@@ -5,6 +5,8 @@ use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap};
use crate::Session;
mod page_lookup;
fn session() -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap();
@@ -0,0 +1,106 @@
use super::super::{PlacedInk, ink_on};
use crate::Session;
use std::sync::Arc;
use wordcraft_doc::freeform::{Freeform, InkTool};
use wordcraft_doc::para::{Float, InlineObject, OBJ, ShapeKind, Wrap};
use wordcraft_doc::{CharProps, Document, Paragraph, Path, Pos, StoryRef, para_block};
use wordcraft_geom::{Rect, Spin};
use wordcraft_layout::{DocLayout, Page, Placed};
fn stroke(order: u32) -> InlineObject {
InlineObject::Shape {
kind: ShapeKind::Freeform,
w: 20.0,
h: 10.0,
fill: None,
stroke: Some(wordcraft_doc::Rgb(12, 34, 56)),
stroke_width: 2.0,
float: Float { wrap: Wrap::InFrontOfText, ..Default::default() },
story: None,
freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(InkTool::Pen, 20.0, 10.0, vec![[1.0, 1.0], [19.0, 9.0]]) })),
effects: Default::default(),
extra: Default::default(),
}
}
fn placed(story: StoryRef, path: Path, off: usize, x: f32) -> Placed {
Placed::Object {
rect: Rect::new(x, 30.0, 20.0, 10.0),
spin: Spin::default(),
story,
path,
off,
text_box: None,
wrap: Wrap::InFrontOfText,
origin: Default::default(),
}
}
fn orders(ink: &[PlacedInk]) -> Vec<u32> {
ink.iter().map(|k| k.freeform.order.unwrap()).collect()
}
#[test]
fn page_ink_keeps_item_order_duplicates_story_paths_and_unmatched_storage() {
let mut d = Document::new();
let mut p = Paragraph::new();
p.text = format!("é{OBJ}{OBJ}{OBJ}漢{OBJ}");
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = vec![InlineObject::BookmarkStart { name: "original".into() }, stroke(11), stroke(22)];
d.body = vec![para_block(p.clone())];
let h = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(p.clone())]);
let mut table = wordcraft_doc::Table::new(1, 1, 100.0);
p.objects.push(stroke(33));
p.objects.push(stroke(99)); // Surplus storage has no character and is not placed.
table.rows[0].cells[0].blocks = vec![para_block(p)];
d.body.push(Arc::new(wordcraft_doc::Block::Table(table)));
let cell = Path(vec![1, 0, 0, 0]);
let mut items = vec![
placed(StoryRef::Body, Path::top(0), 8, 50.0),
placed(StoryRef::Part(h), Path::top(0), 5, 60.0),
placed(StoryRef::Body, cell.clone(), 14, 70.0),
placed(StoryRef::Body, Path::top(0), 5, 80.0),
placed(StoryRef::Body, Path::top(0), 8, 90.0),
];
items.extend([0, 1, 2, 3, 4, 11, 14, usize::MAX].map(|off| placed(StoryRef::Body, Path::top(0), off, 0.0)));
items.push(placed(StoryRef::Part(999), Path::top(0), 0, 0.0));
let l = DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() };
let mut s = Session::new(d);
let original = s.doc.clone();
let got = ink_on(&s, &l, 0);
assert_eq!(orders(&got), [22, 11, 33, 11, 22]);
assert_eq!(got.iter().map(|k| k.rect.x).collect::<Vec<_>>(), [50.0, 60.0, 70.0, 80.0, 90.0]);
assert_eq!(got[1].pos, Pos::new(StoryRef::Part(h), Path::top(0), 5));
assert_eq!(got[2].pos.path, cell);
assert!(got.iter().all(|k| k.width == 2.0 && k.color == Some(wordcraft_doc::Rgb(12, 34, 56))));
assert!(ink_on(&s, &l, 1).is_empty());
s.view.hide_ink = true;
assert_eq!(orders(&ink_on(&s, &l, 0)), orders(&got), "discovery retains hidden ink; callers decide visibility");
assert_eq!(s.doc, original);
}
#[test]
fn page_ink_lookup_work_does_not_multiply_with_paragraph_objects() {
fn fixture(n: usize) -> (Session, DocLayout) {
let mut d = Document::new();
let mut p = Paragraph::new();
p.text = OBJ.to_string().repeat(n);
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = (0..n).map(|i| stroke(i as u32)).collect();
d.body = vec![para_block(p)];
let items = (0..n).map(|i| placed(StoryRef::Body, Path::top(0), i * OBJ.len_utf8(), 50.0)).collect();
(Session::new(d), DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() })
}
let (small, a) = fixture(250);
let (big, b) = fixture(2000);
assert_eq!(ink_on(&small, &a, 0).len(), 250);
assert_eq!(ink_on(&big, &b, 0).len(), 2000);
// The characters the object lookups walk (`wordcraft_doc::work`), not a timing: about 8x for
// 8x the objects when each paragraph is scanned once, about 64x when every lookup rescans it.
let s1 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&small, &a, 0))));
let s8 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&big, &b, 0))));
let ratio = s8 as f64 / s1.max(1) as f64;
eprintln!("page ink steps: {s1} -> {s8}, {ratio:.1}x for 8x objects");
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 12.0, "page ink lookup repeats paragraph-prefix work: {ratio:.1}x the steps for 8x objects ({s1} -> {s8})");
}
+1 -1
View File
@@ -18,7 +18,7 @@ mod styles;
mod tabs_borders;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{fmt, indent, outdent};
pub use format::{cur as selection_para, fmt, indent, outdent};
pub use lists::{ListButtons, list_buttons, manage_list, touch_all};
use lists::{list, restart_numbering};
pub(crate) use outline::{ensure_heading_style, set_heading};
+11 -2
View File
@@ -1,7 +1,8 @@
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
/// Resolved paragraph props at the caret.
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
/// Resolved paragraph props of the selection: those of the paragraph the caret is in, or of the
/// last paragraph a selection covers when it ends at the next paragraph's start.
pub fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
// A selection ending at the next paragraph's start doesn't format that paragraph (#418), so
// its state comes from the last paragraph it does cover.
let (a, b) = s.sel.ordered();
@@ -122,8 +123,16 @@ pub(super) fn set_props(s: &mut Session, v: &Value) -> CmdResult {
for path in crate::cmd::selected_paths(s) {
let Some(para) = s.doc.para(story, &path) else { continue };
let rtl = s.doc.styles.resolve_para(&para.props.clone().overlaid(&props)).bidi;
let anchor_lock = props
.frame
.as_ref()
.filter(|frame| frame.anchor_lock.is_none())
.and_then(|_| para.props.frame.as_ref().and_then(|frame| frame.anchor_lock));
let para = s.doc.para_mut(story, &path)?;
para.props.overlay(&props);
if let (Some(anchor_lock), Some(frame)) = (anchor_lock, para.props.frame.as_mut()) {
frame.anchor_lock = Some(anchor_lock);
}
if let Some(a) = align {
para.props.align = Some(a.visual(rtl));
}
+1
View File
@@ -12,6 +12,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
mod fields;
mod notes;
mod numbers;
mod properties;
mod toc;
@@ -1,3 +1,4 @@
use super::numbers::{body_bookmark_paths, para_numbers, ref_number};
use super::properties::{
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
set_summary_property, summary_property, text_result,
@@ -335,6 +336,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
// after one that sets a property shows the new value.
let mut core = s.doc.core.clone();
// Build each body lookup once, only when a supported numeric REF needs it.
let mut numbers = None;
let mut bookmark_paths = None;
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
// REF before the SET shows the last value set, as Word's bookmark does after an update.
@@ -374,6 +378,14 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
let r = if name == "REF" {
if has_switch(instr, 'p') {
None
} else if let Some(sw) = ['w', 'r', 'n'].into_iter().find(|c| has_switch(instr, *c)) {
if has_switch(instr, 'd') || has_switch(instr, 't') {
continue; // Preserve cached results for unsupported delimiter/text switches.
}
let numbers = numbers.get_or_insert_with(|| para_numbers(&s.doc));
let from = (story == StoryRef::Body).then_some(&path);
let paths = bookmark_paths.get_or_insert_with(|| body_bookmark_paths(&s.doc));
paths.get(target).and_then(|tp| ref_number(numbers, tp, from, sw))
} else {
bookmark_text(&s.doc, target).or_else(|| {
let key = target.to_lowercase();
+135
View File
@@ -0,0 +1,135 @@
//! Numbered REF results, adapted from @Alyosha-dot-shop's patch in #4533.
use super::{Counters, NumFormat, Path, StoryRef};
/// What a numbered body paragraph shows: the counter value of each level down to its own, the
/// label each of those levels shows at that point (`["14.", "(a)"]` for 14(a)), and the levels
/// each label's template shows (`"%1.%2"` shows levels 0 and 1).
#[derive(Clone, Debug, Default)]
pub(super) struct ParaNumber {
/// Relative context is shared only within the same numbering instance.
list: u32,
values: Vec<u32>,
/// Whether each level has counted a paragraph yet: a level the list never used (an `(a)` item
/// with no clause above it in its list) shows no number in a reference.
started: Vec<bool>,
labels: Vec<Option<String>>,
shows: Vec<Vec<usize>>,
}
/// Whether the body paragraph at `path` sits in a table cell merged into the one above
/// (`w:vMerge` continue), at any nesting depth. The layout shows nothing of such a cell, so its
/// paragraphs take no list number.
fn in_merged_cell(doc: &wordcraft_doc::Document, path: &Path) -> bool {
let p = &path.0;
let mut j = 0usize;
// [table, row, cell, block, row, cell, block, …]: a table at p[..=j], its row and cell next.
while j.saturating_add(3) < p.len() {
let (Some(tp), Some(&r), Some(&c)) = (p.get(..=j), p.get(j + 1), p.get(j + 2)) else { return false };
let merged = doc
.table(StoryRef::Body, &Path(tp.to_vec()))
.and_then(|t| t.rows.get(r as usize))
.and_then(|row| row.cells.get(c as usize))
.is_some_and(|cell| cell.props.vmerge == wordcraft_doc::props::VMerge::Continue);
if merged {
return true;
}
j = j.saturating_add(3);
}
false
}
/// Every numbered body paragraph's number, by path, walking the lists in document order as the
/// layout does (cells merged into the one above count nothing). Bullets have no number.
pub(super) fn para_numbers(doc: &wordcraft_doc::Document) -> std::collections::HashMap<Path, ParaNumber> {
let mut counters = Counters::default();
let mut out = std::collections::HashMap::new();
for path in doc.para_paths(StoryRef::Body) {
if in_merged_cell(doc, &path) {
continue;
}
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
let Some(n) = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0) else { continue };
let Some((_, def)) = counters.next_label(&doc.numbering, n.num, n.level) else { continue };
if def.format == NumFormat::Bullet {
continue;
}
let level = usize::from(n.level.min(8));
let values = counters.values(&doc.numbering, n.num).unwrap_or_default();
let started = counters.started(&doc.numbering, n.num).unwrap_or_default();
let mut num = ParaNumber { list: n.num, ..Default::default() };
for k in 0..=level {
num.values.push(values.get(k).copied().unwrap_or(0));
num.started.push(started.get(k).copied().unwrap_or(false));
num.labels.push(counters.current_label(&doc.numbering, n.num, k as u8));
num.shows.push(counters.current_template_levels(&doc.numbering, n.num, k as u8));
}
out.insert(path, num);
}
out
}
/// A paragraph number as a cross-reference shows it: without the trailing period ("14." → "14"),
/// keeping a closing bracket ("(a)").
fn ref_part(label: &str) -> &str {
label.trim().trim_end_matches('.').trim_end()
}
/// The number `REF \n`, `\r` or `\w` shows for the bookmarked paragraph `target` (ECMA-376 Part 1
/// §17.16.5.51), as Word computes it:
/// - `\n` (no context): the paragraph's own label.
/// - `\w` (full context): the own label, with the label of each level above it put in front when
/// no label already in the result shows that level: under `%1.` / `%1.%2` / `(%3)`, clause (a)
/// of 1.1 is `1.1(a)`, not `1.1.1(a)`.
/// - `\r` (relative context): like `\w`, but leaving out the leading levels whose counters the
/// field's own paragraph (`from`) shares with the target, compared level by level within the same list. From inside clause 14, 14(a) is `(a)`; from clause 5 or an unnumbered paragraph it
/// is `14(a)`. Context already displayed by the own or a needed ancestor label stays.
///
/// A level above the target that its list never used adds nothing (Word shows `(a)` for an item
/// whose list has no clause above it, not a made-up `1.1(a)`). `None` when the target paragraph
/// has no number.
pub(super) fn ref_number(numbers: &std::collections::HashMap<Path, ParaNumber>, target: &Path, from: Option<&Path>, switch: char) -> Option<String> {
let t = numbers.get(target)?;
let level = t.values.len().checked_sub(1)?;
let own = t.labels.get(level)?.as_deref()?;
let mut out = ref_part(own).to_string();
if switch == 'n' {
return Some(out);
}
let used = |k: usize| t.started.get(k).copied().unwrap_or(false);
let first = match (switch, from.and_then(|p| numbers.get(p))) {
('r', Some(f)) if f.list == t.list => {
(0..level).take_while(|&k| !used(k) || (f.values.get(k).is_some() && f.values.get(k) == t.values.get(k))).count()
}
_ => 0,
};
// Relative context suppresses added ancestors, never text already rendered in a label.
// Word's measured legal-template cases retain 2.2.2 even when 2 or 2.2 is shared.
let mut shown = t.shows.get(level).cloned().unwrap_or_default();
for k in (first..level).rev() {
if shown.contains(&k) || !used(k) {
continue;
}
if t.labels.get(k).is_some_and(Option::is_some) {
let prefix = ref_part(t.labels.get(k)?.as_deref()?);
// Without a delimiter, joining separate numeric labels would invent a number.
if prefix.chars().next_back().is_some_and(char::is_alphanumeric) && out.chars().next().is_some_and(char::is_alphanumeric) {
return None;
}
out.insert_str(0, prefix);
shown.extend(t.shows.get(k).into_iter().flatten().copied());
}
}
Some(out)
}
/// Index the first body paragraph for each bookmark name, retaining document-order lookup semantics.
pub(super) fn body_bookmark_paths(doc: &wordcraft_doc::Document) -> std::collections::HashMap<String, Path> {
let mut paths = std::collections::HashMap::new();
for (name, pos) in doc.bookmarks() {
if pos.story == StoryRef::Body {
paths.entry(name).or_insert(pos.path);
}
}
paths
}
+33 -5
View File
@@ -24,12 +24,12 @@ pub const DEFAULT_H: f32 = 252.0;
const LAYOUTS: &str = "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn";
const COLORS: &str = "accent1|accent2|accent3|accent4|accent5|accent6|colorful";
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7}] (a string's leading tabs are its level)"#;
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}] (a string's leading tabs are its level)"#;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
),
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
let items = items_of(v.get("items").ok_or_else(|| CmdError::Params(format!("`items` is required: {ITEMS}")))?)?;
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
})
.params(r#"{"items": [string | {"text": string, "level"?: 0-7}]} (replaces the outline)"#)
.params(r#"{"items": [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}]} (replaces the outline)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.textPane", "Text Pane", "SmartArt Design › Create Graphic", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.smart_art_pane);
@@ -124,14 +124,36 @@ pub fn specs() -> Vec<CommandSpec> {
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
edit(s, move |g| {
g.colors = colors;
g.clear_custom_colors();
Ok(())
})
})
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"}"#)
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
let i = index(v, "index")?;
let color = match v.get("color") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
};
edit(s, move |g| {
let i = item_at(g, i)?;
if g.style_accent(i).is_none() {
return Err(CmdError::Failed("this item has no shape of its own: it is text in another item's shape".into()));
}
if let Some(it) = g.items.get_mut(i) {
it.color = color;
}
Ok(())
})
})
.params(r#"{"index"?: item index (default: the last), "color"?: "RRGGBB" (none: the style's colour)}"#)
.when(has_smart_art),
CommandSpec::new("smartArt.reset", "Reset Graphic", "SmartArt Design › Reset", |s, _| {
edit(s, |g| {
g.colors = SmartArtColors::default();
g.clear_custom_colors();
Ok(())
})
})
@@ -235,7 +257,13 @@ fn items_of(v: &Value) -> Result<Vec<SmartArtItem>, CmdError> {
Some(other) => clean_text(&other.to_string()),
};
let level = p::u64(it, "level").unwrap_or(0).min(u64::from(MAX_LEVEL));
Ok(SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL)))
let mut item = SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL));
item.color = match it.get("color") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
};
Ok(item)
}
_ => Err(CmdError::Params(format!("`items`: {ITEMS}"))),
})
+27
View File
@@ -924,6 +924,33 @@ mod tests {
#[test]
fn numbers_read_currency_and_separators_but_not_letters() {
assert_eq!(parse_number("$1,200", None), Some(1200.0));
assert_eq!(parse_number("B/. 1,234.50", None), Some(1234.5), "the Panamanian balboa");
assert_eq!(parse_number("1,234.50 B/.", None), Some(1234.5));
assert_eq!(parse_number("(B/. 1,234.50)", None), Some(-1234.5), "an accounting negative");
assert_eq!(parse_number("B/.1,234.50", None), None, "Word needs a space after the sign");
assert_eq!(parse_number("1/.5", None), None, "a fraction is not a balboa amount");
// The formula reads the cell in the document's language. With the document's default text
// as es-PA and the editing language as pl-PL the choice is observable: `1,250.50` is a
// number under es-PA and not under pl-PL (whose decimal separator is `,`).
let balboa = table(&[&["B/. 1,234.50"], &["(B/. 1,234.50)"], &[""]]);
let mut doc = wordcraft_doc::Document::new();
std::sync::Arc::make_mut(&mut doc.styles).default_chr.lang = Some("es-PA".into());
assert_eq!(
evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=SUM(ABOVE)"),
Ok(0.0),
"a balboa cell sums in the document's language, and its negative in parentheses cancels it",
);
assert_eq!(evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=COUNT(ABOVE)"), Ok(2.0), "both balboa cells are numbers",);
// And the other way around: a balboa cell written with Polish separators is read only when
// the document language reaches the parser, not when it falls back to English.
let mut polish = wordcraft_doc::Document::new();
std::sync::Arc::make_mut(&mut polish.styles).default_chr.lang = Some("pl-PL".into());
let balboa_pl = table(&[&["B/. 1 250,50"], &[""]]);
assert_eq!(
evaluate_with(&balboa_pl, Some(&polish), Some("en-US"), 1, 0, "=SUM(ABOVE)"),
Ok(1250.5),
"the cell's language selects the separators; English does not read `1 250,50`",
);
assert_eq!(parse_number(" 12% ", None), Some(12.0));
assert_eq!(parse_number("(5)", None), Some(-5.0));
assert_eq!(parse_number("x9", None), None);
+58
View File
@@ -6045,6 +6045,22 @@ fn props_colours_take_hex_strings() {
}
}
#[test]
fn para_set_frame_geometry_keeps_anchor_lock_unless_replaced() {
use wordcraft_doc::props::TextFrame;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Framed text"}));
let paragraph = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
paragraph.props.frame = Some(TextFrame { anchor_lock: Some(true), ..TextFrame::default() });
run(&mut s, "para.set", json!({"props": {"frame": {"x": 36.0, "y": 72.0, "w": 108.0}}}));
let frame = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w, frame.anchor_lock), (36.0, 72.0, Some(108.0), Some(true)));
run(&mut s, "para.set", json!({"props": {"frame": {"x": 54.0, "anchorLock": false}}}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap().anchor_lock, Some(false));
}
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
@@ -9659,6 +9675,47 @@ fn smart_art_edits_undo_one_step_each() {
run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false}));
}
/// An item's own colour survives item edits and layout changes, and a style colour drops it.
#[test]
fn smart_art_item_colors_stay_until_a_style_is_picked() {
let mut s = s();
run(&mut s, "insert.smartArt", json!({"layout": "process"}));
let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::<Vec<_>>();
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"}));
assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]);
run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]}));
run(&mut s, "smartArt.layout", json!({"layout": "cycle"}));
run(&mut s, "smartArt.addShape", json!({"after": 0}));
assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]);
assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err());
run(&mut s, "smartArt.itemColor", json!({"index": 2}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"}));
run(&mut s, "smartArt.colors", json!({"variant": "accent2"}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "edit.undo", json!({}));
assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back");
}
/// A malformed colour must not silently replace a coloured item with its style colour.
#[test]
fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() {
let mut s = s();
run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]}));
let before = cmd::smart_art::selected_smart_art(&s).unwrap();
for command in ["insert.smartArt", "smartArt.items"] {
for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] {
assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}");
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
}
run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "edit.undo", json!({}));
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
}
}
/// #497: hostile SmartArt params are capped or refused, never a panic.
#[test]
fn smart_art_hostile_params_are_capped() {
@@ -9680,6 +9737,7 @@ fn smart_art_hostile_params_are_capped() {
"smartArt.demote",
"smartArt.layout",
"smartArt.colors",
"smartArt.itemColor",
"smartArt.reset",
"smartArt.textPane",
] {
+2
View File
@@ -15,3 +15,5 @@ mod rtf_sections;
#[cfg(not(target_arch = "wasm32"))]
mod safe_save;
mod three_column_headers;
mod ref_paragraph_numbers;
@@ -0,0 +1,404 @@
//! Synthetic numbered-reference regression adapted from the patch by @Alyosha-dot-shop in #4533.
//! Switch semantics: Microsoft MS-OI29500, Part 1 Section 17.16.5.51 (REF).
//! All OOXML packages and document text are constructed in memory; no external documents.
use std::io::Write;
use serde_json::json;
use wordcraft_doc::{InlineObject, StoryRef};
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
/// Build an OOXML ZIP entirely in memory.
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// One synthetic numbering level.
fn lvl(ilvl: u8, fmt: &str, text: &str) -> String {
format!(r#"<w:lvl w:ilvl="{ilvl}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:lvlText w:val="{text}"/></w:lvl>"#)
}
/// Five independent list schemes for context and restart coverage.
fn numbering() -> String {
let schemes: [&[(&str, &str)]; 5] = [
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
&[("decimal", "%1"), ("decimal", "%1.%2"), ("decimal", "%1.%2.%3")],
&[("decimal", "%1"), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
&[("decimal", "%1."), ("lowerLetter", "(%2)")],
// Scheme 5: as scheme 1, a list of its own whose first two levels are never used.
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
];
let mut out = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering {W}>"#);
for (i, levels) in schemes.iter().enumerate() {
out.push_str(&format!(r#"<w:abstractNum w:abstractNumId="{i}"><w:multiLevelType w:val="multilevel"/>"#));
for (k, (fmt, text)) in levels.iter().enumerate() {
out.push_str(&lvl(k as u8, fmt, text));
}
out.push_str("</w:abstractNum>");
}
for i in 0..schemes.len() {
out.push_str(&format!(r#"<w:num w:numId="{}"><w:abstractNumId w:val="{i}"/></w:num>"#, i + 1));
}
out + "</w:numbering>"
}
/// A paragraph, numbered `(numId, level)` or not, holding `content`.
fn para(num: Option<(u32, u8)>, content: &str) -> String {
let ppr = num.map(|(n, l)| format!(r#"<w:pPr><w:numPr><w:ilvl w:val="{l}"/><w:numId w:val="{n}"/></w:numPr></w:pPr>"#)).unwrap_or_default();
format!("<w:p>{ppr}{content}</w:p>")
}
/// One synthetic text run.
fn text(t: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#)
}
/// A bookmark surrounding a synthetic text run.
fn marked(name: &str, id: u32, t: &str) -> String {
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/>{}<w:bookmarkEnd w:id="{id}"/>"#, text(t))
}
/// A complex REF field with an intentionally stale result.
fn field(instr: &str, cached: &str) -> String {
format!(
concat!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
r#"<w:r><w:instrText xml:space="preserve"> {} </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
r#"<w:r><w:t xml:space="preserve">{}</w:t></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
),
instr, cached
)
}
/// Open a synthetic OOXML package through the real document reader.
fn open(body: &str) -> wordcraft_engine::Session {
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/></Relationships>"#;
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W}><w:body>{body}</w:body></w:document>"#);
let numbering = numbering();
let bytes =
zip(&[("_rels/.rels", rels), ("word/_rels/document.xml.rels", doc_rels), ("word/document.xml", &doc), ("word/numbering.xml", &numbering)]);
wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("d.docx", &bytes).expect("opens"))
}
/// Every body field, as (code, result), in document order.
fn fields(s: &wordcraft_engine::Session) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for o in &p.objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.trim().to_string(), result.clone()));
}
}
}
out
}
/// Numeric REF switches replace cached values with list labels, while plain REF and position switches retain their behavior.
#[test]
fn ref_number_switches_show_the_paragraph_number_as_word_does() {
let body = [
// Scheme 1: %1. / %1.%2 / (%3) — clause (a) of 1.1.
para(Some((1, 0)), &text("Definitions")),
para(Some((1, 1)), &text("Defined terms")),
para(Some((1, 2)), &marked("_RefA", 1, "Synthetic target alpha.")),
// Scheme 2: the stock chapter scheme %1 / %1.%2 / %1.%2.%3.
para(Some((2, 0)), &text("Chapter")),
para(Some((2, 1)), &text("Section")),
para(Some((2, 2)), &marked("_RefB", 2, "Point")),
// Scheme 3: %1 / %1.%2 / (%3).
para(Some((3, 0)), &text("Part")),
para(Some((3, 1)), &text("Part point")),
para(Some((3, 2)), &marked("_RefC", 3, "Sub-point")),
// Scheme 4: %1. / (%2), clauses and sub-clauses as in most contracts.
para(Some((4, 0)), &text("Hire")),
para(Some((4, 1)), &text("first")),
para(Some((4, 0)), &marked("_RefD", 4, "Payment")),
para(Some((4, 1)), &text("a")),
para(Some((4, 1)), &marked("_RefE", 5, "b")),
// 2(c), inside clause 2: relative references drop the shared clause number.
para(Some((4, 1)), &format!("{}{}{}", text("See "), field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefD \r \h", "STALE"))),
para(Some((4, 0)), &text("Third")),
// 3(a), another clause: the full number. _RefF's list has no clauses above its items.
para(Some((4, 1)), &format!("{}{}", field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefF \r \h", "STALE"))),
// Scheme 5 items in a table whose second row's cell is merged into the first: the empty
// item in the merged-away cell takes no number, so the next item is (b), not (c).
format!(
concat!(
r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>"#,
r#"<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr>{}</w:tc></w:tr>"#,
r#"<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr>{}</w:tc></w:tr></w:tbl>"#,
),
para(Some((5, 2)), &marked("_RefF", 6, "Target F")),
para(Some((5, 2)), ""),
),
para(Some((5, 2)), &marked("_RefG", 7, "Target G")),
// An unnumbered paragraph.
para(
None,
&[
field(r"REF _RefA \w \h", "STALE"),
field(r"REF _RefA \n \h", "STALE"),
field(r"REF _RefA \r \h", "STALE"),
field(r"REF _RefB \w \h", "STALE"),
field(r"REF _RefC \w \h", "STALE"),
field(r"REF _RefE \r \h", "STALE"),
field(r"REF _RefD \n \h", "STALE"),
field(r"REF _RefG \r \h", "STALE"),
// Unchanged: no number switch shows the text; \p (above/below) is left as it is.
field(r"REF _RefB \h", "STALE"),
field(r"REF _RefA \r \p", "KEEP"),
]
.concat(),
),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all": true})).expect("updates");
let got: Vec<(String, String)> = fields(&s);
let want = [
(r"REF _RefE \r \h", "(b)"),
(r"REF _RefD \r \h", "2"),
(r"REF _RefE \r \h", "2(b)"),
(r"REF _RefF \r \h", "(a)"),
(r"REF _RefA \w \h", "1.1(a)"),
(r"REF _RefA \n \h", "(a)"),
(r"REF _RefA \r \h", "1.1(a)"),
(r"REF _RefB \w \h", "1.1.1"),
(r"REF _RefC \w \h", "1.1(a)"),
(r"REF _RefE \r \h", "2(b)"),
(r"REF _RefD \n \h", "2"),
(r"REF _RefG \r \h", "(b)"),
(r"REF _RefB \h", "Point"),
(r"REF _RefA \r \p", "KEEP"),
];
let want: Vec<(String, String)> = want.iter().map(|(i, r)| (i.to_string(), r.to_string())).collect();
assert_eq!(got, want);
}
/// Word retains context already rendered by the target's own label for relative REF.
#[test]
fn relative_number_keeps_context_already_in_the_target_label() {
let body = [
para(Some((2, 0)), &text("Root")),
para(Some((2, 1)), &text("First section")),
para(Some((2, 2)), &marked("_Target", 20, "Synthetic target")),
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
para(Some((2, 1)), &text("Other section")),
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
para(None, &field(r"REF _Target \w", "STALE")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all": true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["1.1.1", "1.1.1", "1.1.1"]);
}
/// Cached values of unsupported switches, missing targets and locked fields are not replaced with paragraph text.
#[test]
fn unsupported_or_unresolvable_numeric_refs_preserve_their_cache() {
let body = [
para(Some((1, 0)), &marked("_Root", 30, "Synthetic root")),
para(
None,
&[
field(r"REF _Root \w \d /", "KEEP DELIMITER"),
field(r"REF _Root \n \t", "KEEP TEXT"),
field(r"REF _Root \r \p", "KEEP POSITION"),
field(r"REF _Missing \w", "KEEP MISSING"),
field(r"REF _Plain \n", "KEEP UNNUMBERED"),
field(r"REF _Root \n", "KEEP LOCKED").replace(r#"w:fldCharType="begin""#, r#"w:fldCharType="begin" w:fldLock="true""#),
]
.concat(),
),
para(None, &marked("_Plain", 31, "Synthetic unnumbered target")),
]
.concat();
let mut s = open(&body);
let before = fields(&s);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s), before);
}
/// Updating, undoing and saving keep the field instructions and their numeric results intact.
#[test]
fn numeric_refs_survive_undo_redo_and_docx_round_trip() {
let body = [
para(Some((4, 0)), &text("Root")),
para(Some((4, 1)), &marked("_Item", 40, "Synthetic item șțăîâ")),
para(None, &field(r"REF _Item \w \h", "STALE")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s), [(r"REF _Item \w \h".into(), "1(a)".into())]);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(fields(&s)[0].1, "STALE");
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(fields(&s)[0].1, "1(a)");
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let back = wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("roundtrip.docx", &bytes).unwrap());
assert_eq!(fields(&back), fields(&s));
}
/// Context already displayed by a needed parent label is retained as Word measured it.
#[test]
fn relative_number_from_the_root_keeps_the_parents_rendered_context() {
let body = [
para(Some((1, 0)), &field(r"REF _Target \r", "STALE")),
para(Some((1, 1)), &text("Synthetic section")),
para(Some((1, 2)), &marked("_Target", 50, "Synthetic target")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s)[0].1, "1.1(a)");
}
/// A header can reference a body list without inheriting the body caret's relative context.
#[test]
fn numeric_refs_in_headers_use_the_body_target_number() {
use wordcraft_doc::{Paragraph, PartKind, para_block};
let mut s = open(&[para(Some((4, 0)), &text("Synthetic root")), para(Some((4, 1)), &marked("_Target", 60, "Synthetic target"))].concat());
let mut header = Paragraph::new();
header
.insert_object(
0,
InlineObject::Field {
instr: r"REF _Target \r \h".into(),
result: "STALE".into(),
locked: false,
dirty: false,
code: false,
form: String::new(),
},
&Default::default(),
)
.unwrap();
let id = s.doc.add_part(PartKind::Header, vec![para_block(header)]);
s.doc.last_section.headers.default = Some(id);
s.run("references.updateFields", &json!({"all":true})).unwrap();
let p = s.doc.para(StoryRef::Part(id), &wordcraft_doc::Path::top(0)).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!() };
assert_eq!(result, "1(a)");
}
/// Separate bare numeric level labels cannot be concatenated without inventing a delimiter.
#[test]
fn ambiguous_numeric_label_join_keeps_the_cached_full_context() {
let mut s = open(
&[
para(Some((2, 0)), &text("Root")),
para(Some((2, 1)), &text("Section")),
para(Some((2, 2)), &marked("_Target", 70, "Synthetic target")),
para(None, &field(r"REF _Target \w", "1.1.1")),
para(None, &field(r"REF _Target \n", "STALE")),
]
.concat(),
);
let abs = std::sync::Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == 1).unwrap();
abs.levels[1].text = "%2".into();
abs.levels[2].text = "%3".into();
s.run("references.updateFields", &json!({"all":true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, s)| s).collect();
assert_eq!(got, ["1.1.1", "1"]);
}
/// Reusing the lookup keeps the first body bookmark even with duplicate names and many updates.
#[test]
fn repeated_numeric_refs_keep_the_first_body_bookmark() {
use wordcraft_doc::{PartKind, Path};
let mut body = [
para(Some((4, 0)), &marked("_Duplicate", 80, "First synthetic target")),
para(Some((4, 0)), &marked("_Duplicate", 81, "Second synthetic target")),
]
.concat();
for _ in 0..64 {
body.push_str(&para(None, &field(r"REF _Duplicate \w \h", "STALE")));
}
body.push_str(&para(None, &field(r"REF _Duplicate \h", "STALE")));
let mut s = open(&body);
assert_eq!(s.doc.bookmarks().iter().filter(|(name, _)| name == "_Duplicate").count(), 2);
// A same-named bookmark outside the body cannot replace the body target.
let header = open(&para(None, &marked("_Duplicate", 82, "Header synthetic target"))).doc.body[0].clone();
let id = s.doc.add_part(PartKind::Header, vec![header]);
s.doc.last_section.headers.default = Some(id);
s.run("references.updateFields", &json!({"all": true})).unwrap();
let got = fields(&s);
assert_eq!(got.len(), 65);
assert!(got[..64].iter().all(|(_, value)| value == "1"));
assert_eq!(got[64].1, "First synthetic target");
assert_eq!(s.doc.bookmarks().iter().find(|(name, _)| name == "_Duplicate").unwrap().1.path, Path::top(0));
}
/// A target's instance must not invent a different label for its actual ancestor paragraph.
#[test]
fn full_context_keeps_the_actual_ancestor_instance_label() {
let mut s = open(
&[
para(Some((1, 0)), &text("Synthetic ancestor")),
para(Some((6, 1)), &marked("_ActualAncestor", 80, "Synthetic child")),
para(None, &field(r"REF _ActualAncestor \w", "STALE")),
]
.concat(),
);
let numbering = std::sync::Arc::make_mut(&mut s.doc.numbering);
numbering.abstracts.iter_mut().find(|a| a.id == 0).unwrap().levels[0].text = "Clause %1.".into();
let mut child = numbering.nums.iter().find(|n| n.id == 1).unwrap().clone();
child.id = 6;
child.level_overrides = vec![
(0, wordcraft_doc::numbering::Level { text: "Part %1.%2.".into(), ..Default::default() }),
(1, wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::LowerLetter, text: "(%2)".into(), ..Default::default() }),
];
numbering.nums.push(child);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s).into_iter().map(|(_, value)| value).collect::<Vec<_>>(), ["Clause 1(a)"]);
}
/// The six legal-list cases measured in Word for Windows by @pakiat:
/// https://github.com/storytold/wordcraft/pull/4609#issuecomment-6557062456
#[test]
fn relative_refs_keep_the_legal_template_in_the_six_measured_contexts() {
let refs = || [field(r"REF _Leaf \r", "STALE"), field(r"REF _Mid \r", "STALE")].concat();
let body = [
para(Some((2, 0)), &text("Synthetic first root")),
para(Some((2, 0)), &refs()),
para(Some((2, 1)), &text("Synthetic first section")),
para(Some((2, 1)), &marked("_Mid", 90, "Synthetic target section")),
para(Some((2, 2)), &text("Synthetic first leaf")),
para(Some((2, 2)), &marked("_Leaf", 91, "Synthetic target leaf")),
para(Some((2, 2)), &refs()),
para(Some((2, 1)), &refs()),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["2.2.2", "2.2", "2.2.2", "2.2", "2.2.2", "2.2"]);
}
/// Equal counter values in another list do not provide shared relative context.
#[test]
fn relative_context_is_not_shared_by_different_lists() {
let mut s = open(
&[
para(Some((4, 0)), &text("Synthetic target root")),
para(Some((4, 1)), &marked("_OtherList", 92, "Synthetic target")),
para(Some((1, 0)), &text("Synthetic separate root")),
para(Some((1, 1)), &field(r"REF _OtherList \r", "STALE")),
]
.concat(),
);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s)[0].1, "1(a)");
}
+171 -32
View File
@@ -88,7 +88,7 @@ pub struct Construction {
pub parts: Vec<GlyphPart>,
}
/// A parsed `MATH` table. Values are font units.
/// A parsed `MATH` table. Values are font units; glyphs can share immutable constructions.
#[derive(Clone, Debug, Default)]
pub struct MathTable {
pub script_percent: f32,
@@ -100,8 +100,8 @@ pub struct MathTable {
pub italics: HashMap<u32, f32>,
pub top_accent: HashMap<u32, f32>,
pub min_connector_overlap: f32,
pub vert: HashMap<u32, Construction>,
pub horiz: HashMap<u32, Construction>,
pub vert: HashMap<u32, Arc<Construction>>,
pub horiz: HashMap<u32, Arc<Construction>>,
}
impl MathTable {
@@ -185,41 +185,59 @@ fn value_map(b: &[u8], at: usize) -> HashMap<u32, f32> {
m
}
fn constructions(b: &[u8], variants: usize, cov_at: usize, count: usize, offsets_at: usize) -> HashMap<u32, Construction> {
fn construction(b: &[u8], c: usize) -> Construction {
let mut con = Construction::default();
let nv = u16_at(b, c + 2).unwrap_or(0) as usize;
for k in 0..nv.min(256) {
let r = c + 4 + k * 4;
let (Some(vg), Some(adv)) = (u16_at(b, r), u16_at(b, r + 2)) else { break };
con.variants.push((vg as u32, adv as f32));
}
if let Some(a) = sub(b, c, c) {
let np = u16_at(b, a + 4).unwrap_or(0) as usize;
for k in 0..np.min(64) {
let r = a + 6 + k * 10;
let (Some(pg), Some(s), Some(e), Some(full), Some(fl)) =
(u16_at(b, r), u16_at(b, r + 2), u16_at(b, r + 4), u16_at(b, r + 6), u16_at(b, r + 8))
else {
break;
};
con.parts.push(GlyphPart {
gid: pg as u32,
start_connector: s as f32,
end_connector: e as f32,
full_advance: full as f32,
extender: fl & 1 != 0,
});
}
}
con
}
fn constructions(
b: &[u8],
variants: usize,
cov_at: usize,
count: usize,
offsets_at: usize,
cache: &mut HashMap<usize, Arc<Construction>>,
) -> HashMap<u32, Arc<Construction>> {
let mut m = HashMap::new();
let Some(cov) = sub(b, variants, cov_at) else { return m };
let glyphs = coverage(b, cov);
let mut offsets = HashMap::new();
for (i, g) in glyphs.iter().enumerate().take(count) {
let Some(c) = sub(b, variants, offsets_at + i * 2) else { continue };
let mut con = Construction::default();
let nv = u16_at(b, c + 2).unwrap_or(0) as usize;
for k in 0..nv.min(256) {
let r = c + 4 + k * 4;
let (Some(vg), Some(adv)) = (u16_at(b, r), u16_at(b, r + 2)) else { break };
con.variants.push((vg as u32, adv as f32));
}
if let Some(a) = sub(b, c, c) {
let np = u16_at(b, a + 4).unwrap_or(0) as usize;
for k in 0..np.min(64) {
let r = a + 6 + k * 10;
let (Some(pg), Some(s), Some(e), Some(full), Some(fl)) =
(u16_at(b, r), u16_at(b, r + 2), u16_at(b, r + 4), u16_at(b, r + 6), u16_at(b, r + 8))
else {
break;
};
con.parts.push(GlyphPart {
gid: pg as u32,
start_connector: s as f32,
end_connector: e as f32,
full_advance: full as f32,
extender: fl & 1 != 0,
});
}
}
if *g != u32::MAX {
m.insert(*g, con);
offsets.insert(*g, c);
}
}
// Malformed coverage may repeat a glyph. Decode only its last valid reference so
// superseded records cannot accumulate in the shared cache.
for (g, c) in offsets {
let con = cache.entry(c).or_insert_with(|| Arc::new(construction(b, c)));
m.insert(g, Arc::clone(con));
}
m
}
@@ -250,8 +268,9 @@ pub fn parse(b: &[u8]) -> Option<MathTable> {
t.min_connector_overlap = u16_at(b, v).unwrap_or(0) as f32;
let nv = u16_at(b, v + 6).unwrap_or(0) as usize;
let nh = u16_at(b, v + 8).unwrap_or(0) as usize;
t.vert = constructions(b, v, v + 2, nv, v + 10);
t.horiz = constructions(b, v, v + 4, nh, v + 10 + nv * 2);
let mut cache = HashMap::new();
t.vert = constructions(b, v, v + 2, nv, v + 10, &mut cache);
t.horiz = constructions(b, v, v + 4, nh, v + 10 + nv * 2, &mut cache);
}
Some(t)
}
@@ -425,6 +444,126 @@ mod tests {
assert!(!cached(&long), "absurdly long names must not be cached");
}
/// A small MathVariants table with two directions and shared or distinct records.
fn construction_bytes(count: u16, distinct: bool) -> Vec<u8> {
let cov = 10 + 4 * usize::from(count);
let con = cov + 10;
let mut b = vec![0; con];
let put = |b: &mut Vec<u8>, at: usize, value: u16| b[at..at + 2].copy_from_slice(&value.to_be_bytes());
put(&mut b, 0, 50);
put(&mut b, 2, cov as u16);
put(&mut b, 4, cov as u16);
put(&mut b, 6, count);
put(&mut b, 8, count);
for i in 0..2 * usize::from(count) {
put(&mut b, 10 + 2 * i, (con + if distinct && i % 2 == 1 { 1674 } else { 0 }) as u16);
}
for (i, v) in [2, 1, 0, count - 1, 0].into_iter().enumerate() {
put(&mut b, cov + 2 * i, v);
}
for shift in 0..if distinct { 2 } else { 1 } {
b.extend_from_slice(&1028u16.to_be_bytes());
b.extend_from_slice(&256u16.to_be_bytes());
for k in 0..256 {
b.extend_from_slice(&7u16.to_be_bytes());
b.extend_from_slice(&(500u16 + k + shift).to_be_bytes());
}
for v in [0u16, 0, 64] {
b.extend_from_slice(&v.to_be_bytes());
}
for _ in 0..64 {
for v in [8u16, 10, 20, 600 + shift, 1] {
b.extend_from_slice(&v.to_be_bytes());
}
}
}
b
}
fn construction_math(variants: &[u8]) -> Vec<u8> {
let v = 10 + 8 + VALUE_COUNT * 4 + 2;
let mut b = vec![0; v];
b[1] = 1;
b[5] = 10;
b[8..10].copy_from_slice(&(v as u16).to_be_bytes());
b.extend_from_slice(variants);
b
}
#[test]
fn shared_constructions_reuse_variant_and_assembly_storage() {
// 16,000 references per direction fit in the table's 16-bit offset domain.
let table = parse(&construction_math(&construction_bytes(16_000, false))).unwrap();
assert_eq!(table.vert.len(), 16_000);
assert_eq!(table.horiz.len(), 16_000);
let first = table.vert.get(&0).unwrap();
assert_eq!(first.variants.len(), 256);
assert_eq!(first.variants[255], (7, 755.0));
assert_eq!(first.parts.len(), 64);
assert_eq!(first.parts[0], GlyphPart { gid: 8, start_connector: 10.0, end_connector: 20.0, full_advance: 600.0, extender: true });
for con in table.vert.values().chain(table.horiz.values()) {
assert_eq!(con.variants.as_ptr(), first.variants.as_ptr(), "one source record must have one variant allocation");
assert_eq!(con.parts.as_ptr(), first.parts.as_ptr(), "one source record must have one assembly allocation");
}
}
#[test]
fn distinct_constructions_keep_values_and_direction_mappings() {
let table = parse(&construction_math(&construction_bytes(4, true))).unwrap();
for glyph in 0..4 {
let con = table.vert.get(&glyph).unwrap();
let other = table.horiz.get(&glyph).unwrap();
assert_eq!(con.variants[0], (7, 500.0 + (glyph % 2) as f32));
assert_eq!(con.parts[0].full_advance, 600.0 + (glyph % 2) as f32);
assert_eq!(con.variants.as_ptr(), other.variants.as_ptr());
}
assert_ne!(table.vert[&0].variants.as_ptr(), table.vert[&1].variants.as_ptr());
}
#[test]
fn shared_constructions_keep_truncated_records_and_absent_offsets() {
let mut variants = construction_bytes(4, false);
let con = u16_at(&variants, 10).unwrap() as usize;
variants.truncate(con + 4 + 10);
// An absent source reference remains absent, rather than aliasing record zero.
variants[10..12].copy_from_slice(&0u16.to_be_bytes());
let table = parse(&construction_math(&variants)).unwrap();
assert!(!table.vert.contains_key(&0));
let first = table.vert.get(&1).unwrap();
assert_eq!(first.variants, [(7, 500.0), (7, 501.0)]);
assert!(first.parts.is_empty());
for con in table.vert.values().chain(table.horiz.values()) {
assert_eq!(con.variants.as_ptr(), first.variants.as_ptr());
}
}
#[test]
fn duplicate_coverage_retains_only_surviving_constructions() {
let mut variants = construction_bytes(3, true);
let cov = u16_at(&variants, 2).unwrap() as usize;
// Format 1 accepts repeated glyph IDs. Each direction keeps its last valid offset.
for (i, value) in [1u16, 3, 7, 7, 7].into_iter().enumerate() {
variants[cov + i * 2..cov + i * 2 + 2].copy_from_slice(&value.to_be_bytes());
}
let mut cache = HashMap::new();
let vert = constructions(&variants, 0, 2, 3, 10, &mut cache);
assert_eq!(vert.len(), 1);
assert_eq!(vert[&7].variants[0], (7, 500.0));
assert_eq!(cache.len(), 1, "superseded records must not stay alive in the cache");
let horiz = constructions(&variants, 0, 4, 3, 16, &mut cache);
assert_eq!(horiz.len(), 1);
assert_eq!(horiz[&7].variants[0], (7, 501.0));
assert_eq!(cache.len(), 2, "each surviving direction can retain its own record");
// A later absent offset must not remove the preceding valid mapping.
variants[14..16].copy_from_slice(&0u16.to_be_bytes());
let table = parse(&construction_math(&variants)).unwrap();
assert_eq!(table.vert.len(), 1);
assert_eq!(table.vert[&7].variants[0], (7, 501.0));
assert_eq!(table.horiz[&7].variants[0], (7, 501.0));
assert!(Arc::ptr_eq(&table.vert[&7], &table.horiz[&7]));
}
#[test]
fn garbage_tables_never_panic() {
assert!(parse(&[]).is_none());
+1
View File
@@ -58,6 +58,7 @@ pub fn notes(doc: &Document) -> Vec<Note> {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
let mut objects = p.objects.iter();
for c in p.text.chars() {
wordcraft_doc::work::steps(1);
if c == OBJ {
match objects.next() {
Some(InlineObject::CommentStart { id }) => {
+8 -35
View File
@@ -64,45 +64,18 @@ fn dense_markers(n: usize) -> Document {
doc
}
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
// performance evidence; the semantic assertions below still run on every platform.
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
#[test]
fn comment_export_discovery_scales_with_unrelated_markers() {
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
assert_eq!(notes(&small), expected);
assert_eq!(notes(&big), expected);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| {
for _ in 0..8 {
std::hint::black_box(notes(&small));
}
});
let b = cpu_cost(|| {
std::hint::black_box(notes(&big));
});
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
} else {
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
}
// The characters the discovery walks (`wordcraft_doc::work`), not a timing: about 8x for 8x
// the markers when the walk is linear, about 64x when it is quadratic. Timed, this failed on
// busy CI machines with a linear walk (58x for a 17 µs baseline).
let (s, b) = (wordcraft_doc::work::count(|| drop(notes(&small))), wordcraft_doc::work::count(|| drop(notes(&big))));
let ratio = b as f64 / s.max(1) as f64;
eprintln!("comment discovery steps: {s} -> {b}, {ratio:.1}x for 8x markers");
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 12.0, "8x the markers cost {ratio:.1}x the steps ({s} -> {b})");
}
+9
View File
@@ -153,6 +153,15 @@ fn rtf_border_style(s: wordcraft_doc::props::BorderStyle) -> &'static str {
BorderStyle::Triple => "\\brdrtriple",
BorderStyle::DotDash => "\\brdrdashd",
BorderStyle::Wave => "\\brdrwavy",
BorderStyle::ThinThickSmallGap => "\\brdrtnthsg",
BorderStyle::ThickThinSmallGap => "\\brdrthtnsg",
BorderStyle::ThinThickThinSmallGap => "\\brdrtnthtnsg",
BorderStyle::ThinThickMediumGap => "\\brdrtnthmg",
BorderStyle::ThickThinMediumGap => "\\brdrthtnmg",
BorderStyle::ThinThickThinMediumGap => "\\brdrtnthtnmg",
BorderStyle::ThinThickLargeGap => "\\brdrtnthlg",
BorderStyle::ThickThinLargeGap => "\\brdrthtnlg",
BorderStyle::ThinThickThinLargeGap => "\\brdrtnthtnlg",
}
}
+31
View File
@@ -1672,3 +1672,34 @@ fn rtf_backslash_space_and_special_breaks() {
let p = doc.body.first().unwrap().as_para().unwrap();
assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F");
}
#[test]
fn compound_borders_round_trip() {
use wordcraft_doc::props::BorderStyle;
let lines = [
("brdrtnthsg", BorderStyle::ThinThickSmallGap),
("brdrthtnsg", BorderStyle::ThickThinSmallGap),
("brdrtnthtnsg", BorderStyle::ThinThickThinSmallGap),
("brdrtnthmg", BorderStyle::ThinThickMediumGap),
("brdrthtnmg", BorderStyle::ThickThinMediumGap),
("brdrtnthtnmg", BorderStyle::ThinThickThinMediumGap),
("brdrtnthlg", BorderStyle::ThinThickLargeGap),
("brdrthtnlg", BorderStyle::ThickThinLargeGap),
("brdrtnthtnlg", BorderStyle::ThinThickThinLargeGap),
];
let body: String = lines.iter().map(|(w, _)| format!("\\pard\\brdrt\\{w}\\brdrw30 {w}\\par\n")).collect();
let rtf = format!("{{\\rtf1\\ansi\n{body}}}");
let tops = |d: &wordcraft_doc::Document| -> Vec<Option<BorderStyle>> {
d.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| !p.plain_text().is_empty())
.map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style))
.collect()
};
let want: Vec<Option<BorderStyle>> = lines.iter().map(|(_, s)| Some(*s)).collect();
let d = import(rtf.as_bytes()).unwrap();
assert_eq!(tops(&d), want);
let back = import(export(&d).as_bytes()).unwrap();
assert_eq!(tops(&back), want);
}
+12 -3
View File
@@ -206,15 +206,24 @@ impl Reader {
"clbrdrb" => self.cell_side = Some('b'),
"clbrdrr" => self.cell_side = Some('r'),
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
| "brdrtbl"
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrtnthtnsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrtnthtnmg" | "brdrthtnlg"
| "brdrtnthlg" | "brdrtnthtnlg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil" | "brdrtbl"
if self.cell_side.is_some() || self.st.border_side.is_some() =>
{
use wordcraft_doc::props::{Border, BorderStyle};
let style = match w {
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
"brdrth" => BorderStyle::Thick,
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
"brdrdb" | "brdrwavydb" => BorderStyle::Double,
"brdrtnthsg" => BorderStyle::ThinThickSmallGap,
"brdrthtnsg" => BorderStyle::ThickThinSmallGap,
"brdrtnthtnsg" => BorderStyle::ThinThickThinSmallGap,
"brdrtnthmg" => BorderStyle::ThinThickMediumGap,
"brdrthtnmg" => BorderStyle::ThickThinMediumGap,
"brdrtnthtnmg" => BorderStyle::ThinThickThinMediumGap,
"brdrtnthlg" => BorderStyle::ThinThickLargeGap,
"brdrthtnlg" => BorderStyle::ThickThinLargeGap,
"brdrtnthtnlg" => BorderStyle::ThinThickThinLargeGap,
"brdrdot" => BorderStyle::Dotted,
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
+2
View File
@@ -62,6 +62,7 @@ struct Card {
fn root(doc: &Document, id: u32) -> u32 {
let mut cur = id;
for _ in 0..MAX_PARENT_CHAIN {
crate::work::steps(1);
match doc.comments.get(&cur).and_then(|c| c.parent) {
Some(p) if p != cur && doc.comments.contains_key(&p) => cur = p,
_ => break,
@@ -79,6 +80,7 @@ pub fn anchors(doc: &Document) -> Vec<(u32, Pos)> {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (off, object) in p.objects_with_offsets() {
let InlineObject::CommentStart { id } = object else { continue };
crate::work::steps(1);
if !doc.comments.contains_key(id) || root(doc, *id) != *id || !seen.insert(*id) {
continue;
}
+55 -9
View File
@@ -49,18 +49,64 @@ fn dense_markers(n: usize) -> Document {
doc
}
/// Asserts `check` on `make(SCALE * n)` takes at most 1.5 * SCALE times the steps it takes on
/// `make(n)`: about 8x for 8x the input when the work is linear, about 64x when it is quadratic.
/// The steps, not a timing, are both counters' together: the paragraph walks, the paragraph list
/// and the path lookups (`wordcraft_doc::work`), and the comment lookups (`crate::work`).
fn assert_linear_anchor_work(what: &str, n: usize, make: impl Fn(usize) -> Document, check: impl Fn(&Document, usize)) {
let (small, big) = (make(n), make(crate::tests::SCALE * n));
let steps = |doc: &Document, n| {
let mut in_doc = 0;
let in_layout = crate::work::count(|| in_doc = wordcraft_doc::work::count(|| check(doc, n)));
(in_doc, in_layout)
};
let ((sd, sl), (bd, bl)) = (steps(&small, n), steps(&big, crate::tests::SCALE * n));
let (s, b) = (sd + sl, bd + bl);
let ratio = b as f64 / s.max(1) as f64;
eprintln!("{what}: {s} -> {b} steps ({sd} -> {bd} in wordcraft_doc, {sl} -> {bl} in layout), {ratio:.1}x for 8x the input");
assert!(sd > 0, "{what}: no steps counted in wordcraft_doc, so the walks this test watches no longer count them (wordcraft_doc::work)");
assert!(sl > 0, "{what}: no steps counted in layout, so the comment lookups this test watches no longer count them (crate::work)");
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "{what}: work grew {ratio:.1}x for 8x the input ({s} -> {b} steps)");
}
#[test]
fn anchors_walk_dense_unrelated_markers_in_linear_work() {
const N: usize = 2_000;
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
// The characters the paragraph's object lookups walk (`wordcraft_doc::work`), not a timing:
// about 8x for 8x the markers when the walk is linear, about 64x when it is quadratic.
let (s, b) = (wordcraft_doc::work::count(|| check(&small, N)), wordcraft_doc::work::count(|| check(&big, crate::tests::SCALE * N)));
let ratio = b as f64 / s.max(1) as f64;
eprintln!("balloon anchor steps: {s} -> {b}, {ratio:.1}x for 8x markers");
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "balloon anchor work grew {ratio:.1}x for 8x markers ({s} -> {b} steps)");
assert_linear_anchor_work("balloon anchors, dense markers", 2_000, dense_markers, check);
}
/// `n` paragraphs, every other one in a table cell, each holding an unrelated marker, the start
/// of a reply, the start of the comment it answers twice and the start of a missing comment.
fn threads(n: usize) -> Document {
let mut doc = Document::new();
doc.body = (0..n as u32)
.map(|i| {
let (top, reply) = (2 * i, 2 * i + 1);
doc.comments.insert(top, Comment::default());
doc.comments.insert(reply, Comment { parent: Some(top), ..Default::default() });
let mut p = Paragraph::with_text("x", CharProps::default());
push(&mut p, InlineObject::BookmarkEnd { name: "unrelated".into() });
for id in [reply, top, top, u32::MAX] {
push(&mut p, InlineObject::CommentStart { id });
}
if i % 2 == 0 {
return para_block(p);
}
let mut table = Table::new(1, 1, 200.0);
table.rows[0].cells[0].blocks = vec![para_block(p)];
std::sync::Arc::new(Block::Table(table))
})
.collect();
doc
}
#[test]
fn anchors_take_linear_work_over_paragraphs_comments_and_replies() {
let check = |doc: &Document, n: usize| {
let ids: Vec<u32> = anchors(doc).into_iter().map(|(id, _)| id).collect();
assert_eq!(ids, (0..n as u32).map(|i| 2 * i).collect::<Vec<_>>());
};
assert_linear_anchor_work("balloon anchors, threads", 500, threads, check);
}
#[test]
+35 -6
View File
@@ -13,7 +13,14 @@ use super::{
/// Cell identity is independent of optional table-style formatting.
pub(crate) enum BoxContext<'a> {
Other,
Cell(Option<&'a para::CellText>),
/// Outer cell edges relative to its text origin (not including wrap distances).
Cell {
text: Option<&'a para::CellText>,
left: f32,
right: f32,
/// False for a turned text frame: its x axis becomes page y after rotation.
clamp: bool,
},
}
pub(crate) struct BoxInput<'a> {
@@ -32,7 +39,7 @@ impl<'a> BoxInput<'a> {
}
pub(crate) fn cell(story: StoryRef, blocks: &'a Blocks, prefix: &'a [u32], width: f32, cell: Option<&'a para::CellText>, depth: usize) -> Self {
Self { story, blocks, prefix, width, context: BoxContext::Cell(cell), depth, frame: None }
Self { story, blocks, prefix, width, context: BoxContext::Cell { text: cell, left: 0.0, right: width, clamp: false }, depth, frame: None }
}
}
@@ -191,9 +198,9 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
// inline (restored for the story around this box when it's done).
let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story));
let empty_cell = para::CellText::default();
let (table, in_cell) = match context {
BoxContext::Other => (None, false),
BoxContext::Cell(table) => (Some(table.unwrap_or(&empty_cell)), true),
let (in_cell, table, cell_edges) = match context {
BoxContext::Other => (false, None, None),
BoxContext::Cell { text, left, right, clamp } => (true, Some(text.unwrap_or(&empty_cell)), clamp.then_some((left, right))),
};
// Word 16.113.3: the override applies in modes 11/12/14, but not mode 15.
let allow_same_style_space =
@@ -290,7 +297,16 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
// Only paragraph-relative placement excludes space-before. Line anchors
// and frameless page/margin fallbacks retain the text-top origin.
let float_y = if float.v_rel == Anchor::Paragraph { para_top } else { y };
let r = float_rect(frame, (0.0, width), float_y, w, h, float);
let mut r = float_rect(frame, (0.0, width), float_y, w, h, float);
// Only move an offset-positioned float when its box crosses the outer
// cell edge. Cell text margins and wrap distances do not move the object.
if let Some((left, right)) = cell_edges
&& !float.outside_cell
&& float.h_align.is_none()
&& float.wrap != Wrap::InFrontOfText
{
r.x = keep_in_cell(r.x, r.w, left, right);
}
let outline = ctx.wrap_outline(o);
excl.extend(wrap_area(r, float, outline.as_deref().map(Vec::as_slice)));
floats.insert(oi, r);
@@ -507,6 +523,19 @@ pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, te
r
}
/// Keep a float's box within the outer cell edges, relative to the cell's text origin.
/// Offsets that fit are unchanged, including those inside the text margins. Wrap distances
/// affect surrounding text only. An oversized object starts at the outer left edge.
pub(crate) fn keep_in_cell(x: f32, w: f32, left: f32, right: f32) -> f32 {
let left = if left.is_finite() { left } else { 0.0 };
let end = right - w;
if !end.is_finite() || end <= left {
return left;
}
// max/min also place a non-finite offset at a finite edge without panicking.
x.max(left).min(end)
}
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's
/// own come from its [`wordcraft_doc::TextBody`].
#[cfg(test)]
+9 -1
View File
@@ -296,10 +296,18 @@ impl PageBuilder<'_> {
self.page_items_start = self.pages.last().map_or(0, |p| p.items.len());
true
}
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
/// Finish the current page: the lines between its columns, its vertical alignment and its
/// footnotes, placed with the section state in effect (margins, gutter, separators).
/// `new_page` starts with this; after it has run, `new_page` finds nothing left to finish.
pub(crate) fn finish_page(&mut self) {
self.close_column_lines();
self.apply_valign();
// The page is aligned once: finishing it again before it is left moves nothing twice.
self.page_valign = wordcraft_doc::section::PageVAlign::Top;
self.flush_notes();
}
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
self.finish_page();
self.beneath = self.sect_beneath;
self.excl.clear();
// Floats placed above their anchors further down this page are known from its start.
+6
View File
@@ -509,6 +509,12 @@ pub(crate) fn layout_pass(
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
let sec = Sec { si, sect, start, tops };
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
// The page the section before ends on is finished with its own margins and footnote
// separators, before this section's replace them: the footnotes pending on it
// belong to the section whose page it is (#5289).
pb.finish_page();
}
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
if mode.web && si > 0 {
// one long page
+8 -2
View File
@@ -8,6 +8,7 @@ use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps};
use wordcraft_doc::{Block, Blocks, Paragraph, Row, StoryRef, Table};
use wordcraft_geom::Rect;
use crate::boxes::BoxContext;
use crate::para::{CellText, ClKind, ParaLayout};
use crate::{BoxInput, BoxLayout, Ctx, Placed, layout_box};
@@ -621,8 +622,13 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
(items, len)
} else {
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } =
layout_box(ctx, BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth));
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } = layout_box(
ctx,
BoxInput {
context: BoxContext::Cell { text, left: -margins[1], right: x1 - x0 - margins[1], clamp: true },
..BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth)
},
);
// A hidden end-of-cell mark: an empty last paragraph takes no room (Word for
// Windows: a row of empty such cells is only its borders).
if cell.props.hide_mark && cell.blocks.last().and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()) {
+169
View File
@@ -7,6 +7,7 @@ use wordcraft_doc::{Pos, Table};
mod autospace;
mod bidi;
mod budget;
mod cell_floats;
mod complex;
mod grid;
mod ligature;
@@ -2958,6 +2959,44 @@ fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
}
#[test]
fn a_section_starting_a_page_flushes_the_page_before_with_its_own_margins() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// Section one ends its last page with a footnote pending; section two starts a new page
// with a wider left margin. The footnotes on the page before keep section one's margin,
// whichever way the new page starts (#5289).
let note_x = |start: SectionStart| {
let mut d = Document::from_text("Section one text.\nTwo");
let note = wordcraft_doc::Paragraph::with_text("The note text.", Default::default());
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
d.insert_object(
&Pos::body(0, 5),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
&Default::default(),
)
.unwrap();
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.start = start;
d.last_section.margin_left = 144.0;
let l = lay(&d);
let x = l
.pages
.iter()
.flat_map(|p| p.items.iter())
.find_map(|it| match it {
Placed::Lines { story: StoryRef::Part(n), x, .. } if *n == id => Some(*x),
_ => None,
})
.unwrap_or_else(|| panic!("the footnote is placed ({start:?})"));
(x, l.pages.len())
};
for start in [SectionStart::NextPage, SectionStart::EvenPage, SectionStart::OddPage] {
let (x, pages) = note_x(start);
assert!((x - 72.0).abs() < 0.01, "{start:?}: the footnote sits at section one's margin, not {x}");
assert!(pages >= 2, "{start:?}: the section starts a page");
}
}
#[test]
fn paragraph_marks_sit_past_text_running_the_other_way() {
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
@@ -5805,6 +5844,110 @@ fn floats_in_headers_and_table_cells_are_drawn() {
assert!(c[0].x > 72.0 && c[0].x < 72.0 + 234.0, "inside the first cell: {:?}", c[0]);
}
/// A float laid out in its cell (`layoutInCell`) whose offset would take it out of the cell is
/// moved in to the cell's outer side (a letterhead logo anchored left of its header cell). One laid out on the page (`layoutInCell="0"`) keeps its offset.
#[test]
fn cell_floats_laid_out_in_the_cell_stay_in_it() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let cell_w = 234.0;
let place = |x: f32, outside_cell: bool, wrap: Wrap| {
let mut d = Document::from_text("Body text.");
let mut t = Table::new(1, 2, 468.0);
let mut cp = wordcraft_doc::Paragraph::with_text("cell", Default::default());
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
cp.insert_object(0, rect_shape(80.0, 48.0, f), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let c = shapes(&lay(&d).pages[0].items);
assert_eq!(c.len(), 1, "cell float drawn");
c[0].x
};
// An offset inside the cell is kept: the cell's text starts 20 pt left of this one.
let text_left = place(20.0, false, Wrap::Tight) - 20.0;
assert!(text_left > 72.0 && text_left < 72.0 + 12.0, "the cell's text starts past its margin: {text_left}");
let cell_left = text_left - 5.4;
// Anchored 95 pt left of the column: moved in to the cell's outer left side.
let left = place(-95.0, false, Wrap::Tight);
assert!((left - cell_left).abs() < 0.5, "pulled in to the left side: {left} vs {text_left}");
// Past the right side: moved in to it.
let right = place(cell_w + 40.0, false, Wrap::Tight);
// (within a point: the cell's border room is not split evenly between its two sides)
assert!((right - (cell_left + cell_w - 80.0)).abs() < 1.0, "pulled in to the right side: {right}");
// Not laid out in the cell: the offset is kept, off the cell or not.
let out = place(-95.0, true, Wrap::Tight);
assert!((out - (text_left - 95.0)).abs() < 0.5, "layoutInCell=0 keeps the offset: {out}");
// Front-of-text shapes do not wrap and keep their offsets even with layoutInCell on.
for offset in [-95.0, cell_w + 40.0] {
let front = place(offset, false, Wrap::InFrontOfText);
assert!((front - (text_left + offset)).abs() < 0.5, "front-of-text offset preserved: {front}");
}
}
/// Cell confinement uses the finite, nonnegative width that float placement actually draws.
#[test]
fn cell_float_bounds_use_the_placed_width() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let place = |width, x, wrap, outside_cell| {
let mut d = Document::from_text("Body");
let mut t = Table::new(1, 2, 468.0);
let mut p = wordcraft_doc::Paragraph::with_text("cell", Default::default());
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
p.insert_object(0, rect_shape(width, 48.0, f), &Default::default()).unwrap();
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 objects = shapes(&lay(&d).pages[0].items);
assert_eq!(objects.len(), 1);
objects[0]
};
// Invalid stored widths are placed as zero-width objects; offsets at either side must
// match an explicitly zero-width object, rather than inventing extra room in the cell.
for offset in [-95.0, 300.0] {
let zero = place(0.0, offset, Wrap::Tight, false);
for stored in [-100.0, f32::NEG_INFINITY, f32::NAN, f32::INFINITY] {
let placed = place(stored, offset, Wrap::Tight, false);
assert_eq!(placed.w, 0.0);
assert!((placed.x - zero.x).abs() < 0.01, "stored width {stored}, offset {offset}: {placed:?} vs {zero:?}");
}
// Neither opt-out path gains confinement from the width normalization.
for (wrap, outside_cell) in [(Wrap::InFrontOfText, false), (Wrap::Tight, true)] {
let zero = place(0.0, offset, wrap, outside_cell);
let negative = place(-100.0, offset, wrap, outside_cell);
assert_eq!(negative.w, zero.w);
assert!((negative.x - zero.x).abs() < 0.01);
}
}
}
#[test]
fn keep_in_cell_bounds() {
use super::boxes::keep_in_cell;
assert_eq!(keep_in_cell(-95.0, 80.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(300.0, 80.0, 0.0, 234.0), 234.0 - 80.0);
assert_eq!(keep_in_cell(20.0, 80.0, 0.0, 234.0), 20.0);
// Wider than the cell, or exactly its width: starts at the outer left side.
assert_eq!(keep_in_cell(50.0, 300.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(50.0, 234.0, 0.0, 234.0), 0.0);
// Nearly full: no clearance is added to the object's position.
assert_eq!(keep_in_cell(-95.0, 230.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(300.0, 230.0, 0.0, 234.0), 4.0);
// Cell edges include its margins; negative offsets can be wholly inside the cell.
assert_eq!(keep_in_cell(-5.0, 80.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(-5.0, 80.0, -4.0, 234.0), -4.0);
assert_eq!(keep_in_cell(-5.15, 63.75, -5.4, 448.2), -5.15);
assert_eq!(keep_in_cell(395.95, 47.25, -5.4, 448.2), 395.95);
// Clamp limits stay finite and ordered even at small scales or with malformed geometry.
for width in [0.1_f32, 0.3, 1.0, 234.0] {
let object = width * 0.98;
for offset in [-width, 0.0, width, f32::NAN, f32::INFINITY] {
let x = keep_in_cell(offset, object, 0.0, width);
assert!(x.is_finite() && x >= 0.0 && x + object <= width + f32::EPSILON * width.max(1.0));
}
}
assert_eq!(keep_in_cell(10.0, 80.0, 0.0, f32::NAN), 0.0);
assert_eq!(keep_in_cell(10.0, f32::NAN, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(10.0, 80.0, f32::NAN, 234.0), 10.0);
}
/// Paragraph anchors exclude space-before; line and frameless page/margin anchors retain it.
#[test]
fn box_float_origins_respect_vertical_anchor_with_space_before() {
@@ -13397,3 +13540,29 @@ fn a_table_moved_by_a_page_break_shows_its_new_page_number() {
.collect();
assert_eq!(shown, ["2"]);
}
/// Cell confinement must not change page-vertical placement through the rotated text axis.
#[test]
fn turned_cell_float_keeps_its_along_text_offset() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let place = |direction, outside_cell| {
let mut doc = Document::from_text("Synthetic body");
let mut table = Table::new(1, 2, 468.0);
table.rows[0].props.height = Some(100.0);
table.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
table.rows[0].cells[0].props.text_direction = direction;
let mut para = Paragraph::with_text("Synthetic cell", Default::default());
let float = Float { wrap: Wrap::Tight, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 150.0, outside_cell, ..Default::default() };
para.insert_object(0, rect_shape(20.0, 10.0, float), &Default::default()).unwrap();
table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(para)];
doc.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table)).unwrap();
let objects = shapes(&lay(&doc).pages[0].items);
assert_eq!(objects.len(), 1);
objects[0]
};
for direction in [TextDirection::Down, TextDirection::Up] {
let confined = place(direction, false);
let reference = place(direction, true);
assert!((confined.y - reference.y).abs() < 0.01, "{direction:?}: {confined:?} vs {reference:?}");
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Synthetic letterhead geometry. No external documents or image assets are used.
use super::*;
use wordcraft_doc::para::{Anchor, Float, Wrap};
use wordcraft_doc::props::VAlign;
use wordcraft_doc::{Paragraph, PartKind};
fn picture(name: &str, w: f32, h: f32, x: f32, y: f32, wrap: Wrap, dist: f32) -> InlineObject {
InlineObject::Image {
media: name.into(),
w,
h,
alt: name.into(),
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, y, dist, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
fn cell_rect(items: &[Placed]) -> Rect {
items.iter().find_map(|p| if let Placed::Cell { rect, .. } = p { Some(*rect) } else { None }).unwrap()
}
/// A body-table letterhead: a central front-of-text box and two pictures close to the cell's
/// outer edges. Both picture boxes fit; neither their offsets nor the box may move inward.
#[test]
fn cell_letterhead_keeps_picture_offsets_inside_the_outer_edges() {
let mut d = Document::from_text("Synthetic body");
d.last_section.page_w = 612.0;
d.last_section.page_h = 792.0;
d.last_section.margin_left = 70.9;
d.last_section.margin_right = 31.5;
d.last_section.margin_top = 31.2;
let mut t = Table::new(1, 1, 453.6);
t.props.style = None;
t.props.width = Some(453.6);
t.props.indent = Some(21.05);
let border = Some(Border { style: BorderStyle::Single, width: 0.5, ..Default::default() });
t.props.borders = Some(Borders { top: border, left: border, bottom: border, right: border, ..Default::default() });
t.rows[0].props.height = Some(63.75);
let mut p = Paragraph::default();
let box_story = d.add_part(PartKind::TextBox, vec![wordcraft_doc::para_block(Paragraph::with_text("Synthetic letterhead", Default::default()))]);
let mut central = rect_shape(332.25, 52.5, Float { wrap: Wrap::InFrontOfText, x: 59.9, y: 5.8, dist: 9.0, ..Default::default() });
if let InlineObject::Shape { story, .. } = &mut central {
*story = Some(box_story);
}
p.insert_object(0, central, &Default::default()).unwrap();
p.insert_object(0, picture("right", 47.25, 61.6, 395.95, 1.4, Wrap::Through, 9.0), &Default::default()).unwrap();
p.insert_object(0, picture("left", 63.75, 56.25, -5.15, 2.0, Wrap::Through, 9.0), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let layout = lay(&d);
let items = &layout.pages[0].items;
let cell = cell_rect(items);
let mut checked = 0;
for item in items {
if let Placed::Object { rect, origin, text_box, .. } = item {
let (x, y) = if text_box.is_some() {
(59.9, 5.8)
} else if rect.w > 60.0 {
(-5.15, 2.0)
} else {
(395.95, 1.4)
};
assert!(rect.x >= cell.x - 0.01 && rect.right() <= cell.right() + 0.01, "the original box fits the outer cell: {rect:?}, {cell:?}");
assert!((rect.x - origin.x - x).abs() < 0.5, "offset {x} was moved: {rect:?}, origin {origin:?}");
assert!((rect.y - origin.y - y).abs() < 0.01, "vertical placement is unchanged");
checked += 1;
}
}
assert_eq!(checked, 3, "two pictures and one central text box");
}
/// A centred header table wider than the text column, with a picture anchored far to its left.
/// The picture touches the outer cell edge, not the text inset or the wrap-distance boundary.
#[test]
fn cell_letterhead_pulls_an_outside_picture_to_the_outer_left_edge() {
for dist in [0.0, 9.0, 24.0] {
let mut d = Document::from_text("Synthetic body");
d.last_section.page_w = 612.0;
d.last_section.page_h = 792.0;
d.last_section.margin_left = 70.85;
d.last_section.margin_right = 70.85;
d.last_section.header = 5.65;
let mut t = Table::new(1, 1, 498.05);
t.props.style = None;
t.props.width_pct = Some(105.9);
t.props.align = Some(Align::Center);
t.rows[0].props.height = Some(67.95);
t.rows[0].cells[0].props.valign = Some(VAlign::Center);
let mut p = Paragraph::with_text("Synthetic header", Default::default());
p.insert_object(0, picture("logo", 79.0, 47.55, -94.7, 2.3, Wrap::BehindText, dist), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
let header = d.add_part(PartKind::Header, vec![Arc::new(Block::Table(t))]);
d.last_section.headers.default = Some(header);
let layout = lay(&d);
let items = &layout.pages[0].header;
let cell = cell_rect(items);
assert!((cell.x - 56.976).abs() < 0.5, "the centred outer cell begins at {cell:?}");
let objects: Vec<_> =
items.iter().filter_map(|p| if let Placed::Object { rect, origin, .. } = p { Some((*rect, *origin)) } else { None }).collect();
assert_eq!(objects.len(), 1);
let (rect, origin) = objects[0];
assert!((rect.x - cell.x).abs() < 0.5, "wrap distance {dist}: picture {rect:?}, outer cell {cell:?}");
assert!((rect.y - origin.y - 2.3).abs() < 0.01, "vertical placement is unchanged");
}
}
/// Confinement follows the particular cell, including asymmetric margins mirrored for RTL.
#[test]
fn cell_float_outer_edges_include_asymmetric_and_mirrored_margins() {
for rtl in [false, true] {
for (offset, at_left) in [(-100.0, true), (300.0, false)] {
let mut d = Document::from_text("Body");
let mut t = Table::new(1, 2, 468.0);
t.props.bidi_visual = rtl;
t.props.cell_margins = Some([0.0, 12.0, 0.0, 3.0]);
let mut p = Paragraph::default();
p.insert_object(0, picture("logo", 40.0, 20.0, offset, 2.0, Wrap::BehindText, 9.0), &Default::default()).unwrap();
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let layout = lay(&d);
let items = &layout.pages[0].items;
let cell = items.iter().find_map(|p| if let Placed::Cell { rect, cell: 1, .. } = p { Some(*rect) } else { None }).unwrap();
let picture = items.iter().find_map(|p| if let Placed::Image { rect, .. } = p { Some(*rect) } else { None }).unwrap();
let edge = if at_left { picture.x - cell.x } else { picture.right() - cell.right() };
assert!(edge.abs() < 0.5, "rtl {rtl}, offset {offset}: picture {picture:?}, cell {cell:?}");
}
}
}
+2
View File
@@ -443,6 +443,8 @@ 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);
// Alt codes in a text box are read before the box is drawn, so it types the character.
crate::keys::field_alt_codes(self, &ctx);
// A failed Recent open, or a failed Save As, is news only until File closes (#2938).
if !self.ui.backstage {
backstage::forget_open_error(&ctx);
+80 -4
View File
@@ -257,6 +257,8 @@ pub struct CanvasState {
/// 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<crate::keys::AltCodeEcho>,
/// Alt+numpad entry in the focused text box, while the page doesn't have the keyboard (#4403).
pub(crate) field_alt_code: crate::keys::FieldAltCode,
/// Create Link is waiting for a click on the text box to link to: the linking box's object.
pub(crate) link_from: Option<wordcraft_doc::Pos>,
/// What screen readers were last told about the document (#488).
@@ -272,15 +274,23 @@ pub struct CanvasState {
/// The rows and columns hovered in Insert › Table's grid this frame: the page shows that
/// table where it would go (#3931). Set by the grid each frame, taken by [`show`].
pub(crate) table_preview: Option<(usize, usize)>,
/// A command (id, params) the page shows run on the selection, without running it: the
/// formatting button the floating mini toolbar is hovered over. Set each frame, taken by [`show`].
pub(crate) command_preview: Option<(String, serde_json::Value)>,
/// The command preview the page last drew, so the bar repaints only when its hover changes.
pub(crate) command_previewed: Option<(String, serde_json::Value)>,
/// The scroll offset held while a command preview is shown, so the page does not move as
/// the previewed text reflows.
pub(crate) preview_scroll: Option<egui::Vec2>,
/// The last previewed table's layout, the copy of the document it was laid out from and the
/// copy's selection, with
/// what it was laid out for (rows, columns, document id, revision, selection, and the
/// what it was laid out for (command, params, document id, revision, selection, and the
/// document's own layout, which a view change replaces), so a still pointer doesn't lay the
/// document out every frame. Set only while the page shows the preview.
table_preview_layout: Option<(TablePreviewKey, wordcraft_doc::Document, wordcraft_engine::Selection, std::sync::Arc<DocLayout>)>,
}
type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
type TablePreviewKey = ((String, serde_json::Value, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
impl CanvasState {
/// Another document is shown in the window: everything about the page that was shown (drags,
@@ -403,6 +413,7 @@ impl Default for CanvasState {
ime_viewport: None,
alt_code: String::new(),
alt_code_echo: None,
field_alt_code: Default::default(),
wheel: Default::default(),
auto_scroll: Default::default(),
scroll_offset: egui::Vec2::ZERO,
@@ -425,6 +436,9 @@ impl Default for CanvasState {
tab_drag: None,
drawn_ibeam: crate::ibeam::DRAWN,
table_preview: None,
command_preview: None,
command_previewed: None,
preview_scroll: None,
table_preview_layout: None,
}
}
@@ -1074,6 +1088,23 @@ fn handle_primary_pointer(
geo: &Geometry,
middle_gesture: bool,
) {
// A press that began on a drop-down's list button opens the list (`content_controls::clicks`) and must
// not move the caret: the button hangs over what follows the control, and a caret placed
// there leaves the control, which closes the list in the frame it opens (#5389).
// A press and its release in one frame (a quick click on a slow frame) leave no `press_origin`;
// the press is still among the frame's events. A table tool owns the pointer instead: a stroke
// may start over the button.
let press = ui.input(|i| {
i.pointer.press_origin().or_else(|| {
i.events.iter().find_map(|e| match e {
egui::Event::PointerButton { pos, pressed: true, .. } => Some(*pos),
_ => None,
})
})
});
if app.canvas.table_tool.is_none() && press.is_some_and(|p| app.controls.on_button(p)) {
return;
}
// 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
@@ -1307,12 +1338,53 @@ fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollA
/// Lays out and paints the document pages for print, Read and Web views, settles viewports, draw
/// rulers and overlays, anchors zoom and scrolling, and routes pointer, selection, IME and caret
/// input.
/// While the page shows a command preview the view stays where it was when the preview began:
/// no scrolling to the caret, and the offset is held against the previewed text's reflow.
fn hold_scroll_for_preview(app: &mut WordApp) -> bool {
if app.canvas.command_previewed.is_none() {
app.canvas.preview_scroll = None;
return false;
}
let at = *app.canvas.preview_scroll.get_or_insert(app.canvas.scroll_offset);
app.canvas.scroll_request = Some(at);
true
}
/// The live selection's rects on screen, from the document's own layout rather than a command
/// preview's, which can move the lines (Align Right) from under the mini toolbar's pointer.
fn live_selection_screen_rects(app: &mut WordApp, rects: &[Rect], scale: f32) -> Vec<Rect> {
let layout = app.session.layout();
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() {
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()
};
selected
.into_iter()
.filter_map(|(pi, r)| {
let pr = rects.get(pi)?;
let scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
Some(Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale)))
})
.collect()
}
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
let layout = prepare_layout(app, ui, full);
let view = prepare_view(app, ui, full, &layout);
let scroll_target = update_caret_scroll(app, &view);
let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) };
let motion = scroll_motion(app, ui, full, &view);
let scroll_area = configure_scroll_area(app, ui, &view, motion);
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
@@ -1429,7 +1501,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// 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);
// Alignment previews can move the selected text while the pointer is still over a mini
// toolbar button. Keep the bar anchored to the live selection until the preview ends, or
// it can move out from under the pointer and flicker between preview and live layouts.
let anchor_rects = if app.canvas.command_previewed.is_some() { live_selection_screen_rects(app, &rects, geo.scale) } else { screen_rects };
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(anchor_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);
paint_read_aloud_highlight(app, &painter, &layout, &rects, &geo, &t);
+6 -3
View File
@@ -184,20 +184,23 @@ pub(super) fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
/// hovering over (#3931). The preview is laid out on a copy, so the document and its undo history
/// change only when a square is clicked.
pub(super) fn shown_layout(app: &mut WordApp) -> std::sync::Arc<DocLayout> {
let Some((rows, cols)) = app.canvas.table_preview.take() else {
let table = app.canvas.table_preview.take().map(|(rows, cols)| ("insert.table".to_string(), json!({"rows": rows, "cols": cols})));
let command = app.canvas.command_preview.take();
app.canvas.command_previewed = command.clone().filter(|_| table.is_none());
let Some((id, params)) = table.or(command) else {
app.canvas.table_preview_layout = None;
return app.session.layout();
};
// The caret can move, or the view change, with the grid still hovered: the table then goes
// somewhere else, or the page is laid out to another width.
let key = ((rows, cols, app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
let key = ((id.clone(), params.clone(), app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
if let Some((k, _, _, l)) = &app.canvas.table_preview_layout
&& k.0 == key.0
&& std::sync::Arc::ptr_eq(&k.1, &key.1)
{
return l.clone();
}
match app.session.preview_layout("insert.table", &json!({"rows": rows, "cols": cols})) {
match app.session.preview_layout(&id, &params) {
Some((doc, sel, l)) => {
app.canvas.table_preview_layout = Some((key, doc, sel, l.clone()));
l
+92
View File
@@ -30,6 +30,11 @@ impl ControlUi {
pub(crate) fn popup_open(&self) -> bool {
self.popup.is_some()
}
/// Whether `p` (screen) is on the list/calendar button of the current control.
pub(crate) fn on_button(&self, p: egui::Pos2) -> bool {
self.button.as_ref().is_some_and(|(b, _)| b.contains(p))
}
}
struct Popup {
@@ -250,3 +255,90 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option<u32> {
});
picked
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, PointerButton};
fn press(at: egui::Pos2) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }
}
fn release(at: egui::Pos2) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }
}
/// A harness showing a drop-down control followed by text, with the caret in the control and
/// its list button on screen.
fn dropdown_harness() -> egui_kittest::Harness<'static, WordApp> {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.run("text.insert", json!({"text": "tail text"})).unwrap();
app.run("caret.docStart", json!({})).unwrap();
app.run("developer.dropDown", json!({"items": ["A", "B"]})).unwrap();
app.run("view.commentsPane", json!({"value": false})).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..6 {
h.step();
}
let before = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&before).is_some(), "the caret starts in the control");
h
}
/// #5389: pressing a drop-down's list button opens the list and leaves the caret in the
/// control, even when text follows the control and the button hangs over it.
#[test]
fn pressing_the_list_button_keeps_the_caret_in_the_control() {
let mut h = dropdown_harness();
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.event(press(at));
h.step();
h.event(release(at));
for _ in 0..3 {
h.step();
}
let after = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
assert!(h.state().controls.popup_open(), "the list is open");
}
/// The same click when press and release arrive in one frame: egui has no press origin left
/// by then.
#[test]
fn a_click_that_presses_and_releases_in_one_frame_keeps_the_caret_in_the_control() {
let mut h = dropdown_harness();
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.step();
// `Harness::event` runs a frame per event, so both go into the frame's input directly.
h.input_mut().events.extend([press(at), release(at)]);
h.step();
for _ in 0..3 {
h.step();
}
let after = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
assert!(h.state().controls.popup_open(), "the list is open");
}
/// A table tool owns the pointer: a press over the list button starts a stroke.
#[test]
fn the_draw_table_tool_starts_a_stroke_over_the_list_button() {
let mut h = dropdown_harness();
h.state_mut().canvas.table_tool = Some(crate::table_pen::TableTool::Draw);
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.event(press(at));
h.step();
assert!(h.state().canvas.table_stroke.is_some(), "the stroke started on the button");
}
}
+133 -16
View File
@@ -293,19 +293,20 @@ fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>)
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, down to [`MIN_COLUMN`].
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if spare >= 0.0 && weight > 0.0 {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
(w + spare * w / flexible).max(MIN_COLUMN)
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
@@ -314,26 +315,30 @@ fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
/// whatever width is left but never shrinks below its opening width, so widening a column grows
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
/// than the window scrolls sideways. Text too long for its column is clipped.
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
let last = widths.len().saturating_sub(1);
for (i, w) in widths.into_iter().enumerate() {
let c = if i == last {
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
} else {
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
};
t = t.column(c.clip(true));
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
@@ -579,4 +584,116 @@ mod tests {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
+15 -2
View File
@@ -764,8 +764,21 @@ fn draw_line(p: &egui::Painter, [a, b]: [egui::Pos2; 2], style: BorderStyle, wid
(0..=n.min(400)).map(|i| a + dir * (len * i as f32 / n as f32) + normal * if i % 2 == 0 { -1.5 } else { 1.5 }).collect();
p.add(egui::Shape::line(pts, Stroke::new(1.0, color)));
}
BorderStyle::Single | BorderStyle::Thick => {
p.line_segment([a, b], if style == BorderStyle::Thick { Stroke::new((px * 1.5).min(6.0), color) } else { stroke });
BorderStyle::Thick => {
p.line_segment([a, b], Stroke::new((px * 1.5).min(6.0), color));
}
// A compound thin and thick line shows as one line of its width, as on the page.
BorderStyle::Single
| BorderStyle::ThinThickSmallGap
| BorderStyle::ThickThinSmallGap
| BorderStyle::ThinThickThinSmallGap
| BorderStyle::ThinThickMediumGap
| BorderStyle::ThickThinMediumGap
| BorderStyle::ThinThickThinMediumGap
| BorderStyle::ThinThickLargeGap
| BorderStyle::ThickThinLargeGap
| BorderStyle::ThinThickThinLargeGap => {
p.line_segment([a, b], stroke);
}
}
}
+125 -6
View File
@@ -12,6 +12,7 @@ use wordcraft_doc::smart_art::{MAX_ITEMS, SmartArtColors, SmartArtLayout, SmartA
use crate::WordApp;
use crate::theme::{Tokens, c32, regular, semibold};
use crate::widgets;
/// The picker's categories (`All` first), in the order shown.
pub const CATEGORIES: [&str; 8] = ["All", "List", "Process", "Cycle", "Hierarchy", "Relationship", "Matrix", "Pyramid"];
@@ -151,6 +152,21 @@ pub fn swatches(p: &Painter, r: Rect, colors: SmartArtColors, theme: &[Rgb]) {
}
}
/// Swatches in `r` of the colours the items of `g` have now: their own or the style's.
pub fn custom_swatches(p: &Painter, r: Rect, g: &SmartArtSpec, theme: &[Rgb]) {
let shown: Vec<Color32> = (0..g.items.len())
.filter_map(|i| match g.items.get(i)?.color {
Some(c) => Some(c32(c)),
None => Some(accent(theme, g.style_accent(i)?)),
})
.take(5)
.collect();
let w = r.width() / shown.len().max(1) as f32;
for (k, c) in shown.into_iter().enumerate() {
p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w - 2.0, r.height())), 2.0, c);
}
}
/// Height of the picker's three columns, points: three rows of 96 pt tiles and the gaps between them.
const PICKER_HEIGHT: f32 = 3.0 * 96.0 + 2.0 * 8.0;
@@ -262,8 +278,12 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
/// The `smartArt.items` params for `g` with item `i`'s text replaced by `text`.
pub fn items_with(g: &SmartArtSpec, i: usize, text: &str) -> Value {
let items: Vec<Value> =
g.items.iter().enumerate().map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level})).collect();
let items: Vec<Value> = g
.items
.iter()
.enumerate()
.map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level, "color": it.color.map(|c| c.hex())}))
.collect();
json!({"items": items})
}
@@ -275,7 +295,9 @@ pub fn items_without(g: &SmartArtSpec, i: usize) -> Value {
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(j, it)| json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }}))
.map(|(j, it)| {
json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }, "color": it.color.map(|c| c.hex())})
})
.collect();
json!({"items": items})
}
@@ -353,6 +375,49 @@ pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult {
SmartArtTypeResult::Changed
}
/// Side of an item's colour button, points.
const SWATCH: f32 = 18.0;
/// Id of item `i`'s colour button.
fn color_button_id(i: usize) -> egui::Id {
egui::Id::unique(("smart_art_color_button", i))
}
/// The button at the right of item `i`'s row: its shape's colour (the style's until changed).
/// A click offers the colour grid; the style's colour is offered back.
fn color_button(app: &mut WordApp, ui: &mut Ui, g: &SmartArtSpec, i: usize) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::hover());
let resp = ui.interact(rect, color_button_id(i), Sense::click());
let label = g
.items
.get(i)
.filter(|it| !it.text.is_empty())
.map_or_else(|| format!("{} ({})", tl!("Shape color"), i + 1), |it| format!("{}: {}", tl!("Shape color"), it.text));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label.as_str()));
let Some(accent_n) = g.style_accent(i) else { return };
let theme = app.session.doc.settings.theme_colors.clone();
let own = g.items.get(i).and_then(|it| it.color);
let fill = own.map_or_else(|| accent(&theme, accent_n), c32);
ui.painter().rect_filled(rect, 3.0, fill);
let line = if resp.hovered() { t.accent } else { t.border_strong };
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, line), egui::StrokeKind::Inside);
let resp = resp.on_hover_text(tl!("Shape color"));
egui::Popup::menu(&resp).show(|ui| {
let mut pick = widgets::color_grid(ui, &theme).map(|hex| json!({"index": i, "color": hex}));
ui.add_space(2.0);
ui.separator();
if ui.add_enabled(own.is_some(), egui::Button::new(tl!("Style Color")).frame(false)).clicked() {
pick = Some(json!({"index": i}));
}
if let Some(params) = pick {
app.smart_art_typing = None;
let _ = app.run("smartArt.itemColor", params);
ui.close();
}
});
}
/// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one
/// undo step per item typed in); Enter adds an item after the current one; Tab demotes the
/// focused item and Shift+Tab promotes it, as Word's Text Pane does.
@@ -372,9 +437,9 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
ui.label(egui::RichText::new("•").color(t.text_dim));
let mut text = it.text.clone();
let item_name = format!("{} {}", tl!("Text Pane"), i + 1);
let r = ui
.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY))
.accessible_name(item_name);
let width = (ui.available_width() - SWATCH - ui.spacing().item_spacing.x).max(40.0);
let r = ui.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(width)).accessible_name(item_name);
color_button(app, ui, &g, i);
if want_focus == Some(i) {
r.request_focus();
}
@@ -438,6 +503,22 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
mod tests {
use super::*;
#[test]
fn color_buttons_expose_item_names_and_empty_row_indices() {
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[0].text = "Named".into();
spec.items[1].text.clear();
let mut h = egui_kittest::Harness::builder().build_ui(move |ui| {
color_button(&mut app, ui, &spec, 0);
color_button(&mut app, ui, &spec, 1);
});
h.run_steps(2);
h.get_by_label("Shape color: Named");
h.get_by_label("Shape color (2)");
}
#[test]
fn removing_an_item_lifts_the_items_under_it() {
let g = SmartArtSpec::sample(SmartArtLayout::Hierarchy);
@@ -447,6 +528,44 @@ mod tests {
assert_eq!(items_with(&g, 0, "Boss")["items"][0]["text"], "Boss");
}
/// The colour button opens a grid: a swatch sets the item's own colour and Style Color clears it.
#[test]
fn the_color_button_sets_and_clears_an_items_colour() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("insert.smartArt", json!({"layout": "process"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
let mut n = 0u32;
let mut frame = |ctx: &egui::Context, events: Vec<egui::Event>, a: &mut WordApp| {
n += 1;
let input = egui::RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
ctx.run_ui(input, |ui| text_pane(a, ui)).drop_without_applying_deltas();
};
let click = |ctx: &egui::Context, pos: egui::Pos2, a: &mut WordApp, frame: &mut dyn FnMut(&egui::Context, Vec<egui::Event>, &mut WordApp)| {
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(ctx, vec![egui::Event::PointerMoved(pos), button(true)], a);
frame(ctx, vec![button(false)], a);
frame(ctx, vec![], a);
};
let colour =
|a: &WordApp| wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).and_then(|g| g.items.get(1).and_then(|i| i.color));
frame(&ctx, vec![], &mut a);
let button = ctx.read_response(color_button_id(1)).expect("item 1 has a colour button");
let popup = egui::Popup::default_response_id(&button);
click(&ctx, button.rect.center(), &mut a, &mut frame);
assert!(egui::Popup::is_id_open(&ctx, popup), "the button opens the colour grid");
let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn");
// The first theme swatch: under the "Theme Colors" label at the popup's top left.
click(&ctx, area.min + vec2(14.0, 38.0), &mut a, &mut frame);
assert!(colour(&a).is_some(), "a swatch gives the item its own colour");
let button = ctx.read_response(color_button_id(1)).unwrap();
click(&ctx, button.rect.center(), &mut a, &mut frame);
let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn again");
click(&ctx, pos2(area.min.x + 30.0, area.max.y - 14.0), &mut a, &mut frame);
assert_eq!(colour(&a), None, "Style Color clears it");
}
/// #2832: the separators between the picker's columns made the dialog as tall as the window.
#[test]
fn the_picker_row_stays_within_its_height() {
+1 -1
View File
@@ -12,7 +12,7 @@ mod canvas;
mod ime;
mod shortcuts;
pub(crate) use alt_code::AltCodeEcho;
pub(crate) use alt_code::{AltCodeEcho, FieldAltCode, field_alt_codes};
pub use canvas::canvas_events;
pub(crate) use ime::observe_ime;
#[cfg(test)]
+117 -1
View File
@@ -29,11 +29,15 @@ fn alt_code_digit(key: Key) -> Option<char> {
char::from_digit(n, 10)
}
/// Alt is the only modifier held: the one an Alt code's digits are typed with.
fn alt_only(m: &Modifiers) -> bool {
m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd
}
/// 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).
pub(super) 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).
@@ -131,3 +135,115 @@ pub(super) fn alt_code_echoed(app: &mut WordApp, echo: &AltCodeEcho, text: &str)
}
true
}
/// Alt+numpad entry in the focused text box (Find what, a dialog's fields, the ribbon's boxes);
/// the page has its own ([`alt_code_event`]), #4403.
#[derive(Clone, Debug, Default)]
pub(crate) struct FieldAltCode {
/// The digits typed while Alt is held.
code: String,
/// The last digit's own text (Linux sends it with Alt held) isn't typed.
swallow_text: bool,
/// What the last code typed, for the platform's own composed character that may follow.
echo: Option<AltCodeEcho>,
}
/// The character an Alt code names in the code pages the page uses for it: the system locale's
/// on Windows, else `text.typeCode`'s defaults (Windows-1252 and the locale's OEM page).
fn field_code_char(code: &str) -> Option<char> {
use wordcraft_engine::cmd::text::{alt_code_char, system_code_pages, system_oem_code_page};
let (ansi, oem) = if cfg!(target_os = "windows") { system_code_pages() } else { (1252, system_oem_code_page()) };
alt_code_char(code, ansi, oem)
}
/// Whether `e` lets go of Alt: its key's release, or modifiers without it.
fn alt_released(e: &egui::Event) -> bool {
match e {
egui::Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => true,
egui::Event::ModifiersChanged(m) => !m.alt,
_ => false,
}
}
/// Alt codes in the focused text box, as on the page (#4403). Rewrites this frame's input before
/// the box reads it: the digits typed with Alt held type nothing (Linux sends their text), and
/// letting go of Alt types the code's character. A platform that composes the code itself
/// (Windows) sends its character after that: the same character isn't typed twice, and for a
/// code up to 255 the system's character replaces ours, as on the page ([`alt_code_echoed`]).
pub(crate) fn field_alt_codes(app: &mut WordApp, ctx: &egui::Context) {
if !alt_codes_supported(ctx) || app.canvas.focused || !ctx.text_edit_focused() {
app.canvas.field_alt_code = FieldAltCode::default();
return;
}
let st = &mut app.canvas.field_alt_code;
let events = ctx.input(|i| i.events.clone());
let mut out = Vec::with_capacity(events.len() + 1);
let mut changed = false;
for e in events {
match &e {
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
st.swallow_text = false;
st.echo = None;
match alt_code_digit(*key).filter(|_| alt_only(modifiers)) {
Some(d) => {
// A held digit's auto-repeat adds nothing; past seven digits a code
// names nothing, so stop collecting rather than grow.
if !*repeat && st.code.len() < 8 {
st.code.push(d);
}
st.swallow_text = true;
}
// Another key (Alt itself, a letter, a digit with Ctrl) ends the entry.
None => st.code.clear(),
}
}
egui::Event::Text(_) if std::mem::take(&mut st.swallow_text) => {
changed = true;
continue;
}
egui::Event::Text(t) => {
if let Some(echo) = st.echo.take()
&& (echo.typed == *t || echo.system_composes)
{
changed = true;
if echo.typed != *t && echo.byte_code && t.chars().count() == 1 {
// Ours was typed just before, with no key or click since: take it back.
out.push(egui::Event::Key {
key: Key::Backspace,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Modifiers::NONE,
});
out.push(e);
}
continue;
}
}
egui::Event::PointerButton { pressed: true, .. } => st.echo = None,
// Focus elsewhere (another window) ends an Alt code unfinished.
egui::Event::WindowFocused(false) => st.code.clear(),
_ => {}
}
let released = alt_released(&e);
out.push(e);
if released && !st.code.is_empty() {
let code = std::mem::take(&mut st.code);
let system_composes = cfg!(target_os = "windows");
// A digit's own text comes with its key, before Alt is let go: text after this is
// the system's composed character (Windows), also for a code we name no character
// for (a double-byte lead byte, say), or what is typed next. A digit that sent no
// text must not cost it.
st.swallow_text = false;
if let Some(c) = field_code_char(&code) {
let byte_code = code.trim_start_matches('0').parse::<u32>().is_ok_and(|v| v <= 255);
st.echo = Some(AltCodeEcho { typed: c.to_string(), system_composes, byte_code });
out.push(egui::Event::Text(c.to_string()));
changed = true;
}
}
}
if changed {
ctx.input_mut(|i| i.events = out);
}
}
+102
View File
@@ -376,6 +376,108 @@ fn altgr_types_its_characters_without_keytips() {
assert_eq!(a.ui.keytips, crate::keytips::Phase::Tabs);
}
/// The Find dialog's Find what box, after the frames that open the dialog and focus the box.
fn find_box_harness() -> egui_kittest::Harness<'static, WordApp> {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.dialog = crate::dialogs::Dialog::open("find", &mut a);
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
}
/// What the Find dialog's Find what box holds.
fn find_box(h: &egui_kittest::Harness<'static, WordApp>) -> String {
match &h.state().dialog {
Some(crate::dialogs::Dialog::Find { query, .. }) => query.clone(),
_ => panic!("the Find dialog is open"),
}
}
/// One frame: Alt held, `digits` pressed and released (with their text, as Linux sends it, when
/// `digit_text`), Alt let go; then `after`, which a platform may send once Alt is up.
fn alt_code_frame(h: &mut egui_kittest::Harness<'static, WordApp>, digits: &str, digit_text: bool, after: &[&str]) {
let alt = Modifiers { alt: true, ..Default::default() };
let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers };
h.event(egui::Event::ModifiersChanged(alt));
h.event(key(Key::AltLeft, true, alt));
for d in digits.chars() {
let k = Key::from_name(&d.to_string()).expect("a digit key");
h.event(key(k, true, alt));
if digit_text {
h.event(egui::Event::Text(d.to_string()));
}
h.event(key(k, false, alt));
}
h.event(key(Key::AltLeft, false, alt));
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
for t in after {
h.event(egui::Event::Text((*t).to_string()));
}
h.step();
}
/// #4403: an Alt code typed in a text box (here Find what) types its character there, as on the
/// page. Before, the box got the digits (Linux sends their text with Alt held), or on Windows only
/// what the system composes, a byte for a code above 255. The page's text is left alone.
#[test]
fn alt_code_types_its_character_in_a_text_box() {
if cfg!(target_os = "macos") {
return;
}
// The code pages the page's Alt codes use (`text.typeCode`).
let (ansi, oem) = if cfg!(target_os = "windows") {
wordcraft_engine::cmd::text::system_code_pages()
} else {
(1252, wordcraft_engine::cmd::text::system_oem_code_page())
};
let ch = |code| wordcraft_engine::cmd::text::alt_code_char(code, ansi, oem).expect("a character code").to_string();
let mut h = find_box_harness();
assert_eq!(find_box(&h), "");
// Linux: each digit's text arrives too; none of it is typed.
alt_code_frame(&mut h, "0233", true, &[]);
let mut want = ch("0233");
assert_eq!(find_box(&h), want);
// Above 255 the code is the Unicode character's (Word's €), and a platform's echo of the
// same character isn't typed twice.
alt_code_frame(&mut h, "8364", false, &["\u{20AC}"]);
want.push('\u{20AC}');
assert_eq!(find_box(&h), want);
// Windows composes a code itself: up to 255 its character replaces ours (it knows the code
// pages), above that ours stays (it reduces the code to a byte). Elsewhere text after the
// release is typed as it comes.
alt_code_frame(&mut h, "0199", false, &["\u{1E9E}"]);
alt_code_frame(&mut h, "9731", false, &["X"]);
if cfg!(target_os = "windows") {
want.push_str("\u{1E9E}\u{2603}");
} else {
want.push_str(&format!("{}\u{1E9E}\u{2603}X", ch("0199")));
}
assert_eq!(find_box(&h), want);
// A code we name no character for (past Unicode here; a double-byte lead byte on Windows)
// types nothing of ours, and the character the system composes for it is typed, not taken
// for a digit's text. Windows sends no digit text; Linux does.
assert_eq!(wordcraft_engine::cmd::text::alt_code_char("1114112", ansi, oem), None);
alt_code_frame(&mut h, "1114112", !cfg!(target_os = "windows"), &["Y"]);
want.push('Y');
assert_eq!(find_box(&h), want);
// Digits without Alt type themselves; a bare Alt tap types nothing and opens no keytips.
h.event(egui::Event::Key { key: Key::Num5, physical_key: Some(Key::Num5), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.event(egui::Event::Text("5".into()));
h.step();
alt_code_frame(&mut h, "", false, &[]);
want.push('5');
assert_eq!(find_box(&h), want);
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off);
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "", "the page typed nothing");
}
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)
+102 -35
View File
@@ -165,6 +165,9 @@ pub fn track_dismissal(app: &mut WordApp, ctx: &egui::Context, viewport: Rect) {
/// canvas area it must stay inside.
pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: Rect) {
if !visible(app) {
if app.canvas.command_previewed.is_some() {
ctx.request_repaint();
}
return;
}
let Some(bar) = anchor_rect(selection.min.y, selection.min.x, viewport) else { return };
@@ -192,7 +195,11 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R
let inner = bar.shrink2(Vec2::new(PAD_X, PAD_Y));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::top_down(Align::Min)));
child.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
content(app, &mut child, &t);
content(app, &mut child, &t, true);
// The page was drawn before the bar this frame: another frame shows or clears the preview.
if app.canvas.command_preview != app.canvas.command_previewed {
ctx.request_repaint();
}
let used = child.min_rect();
ctx.data_mut(|d| d.insert_temp(shown_id(), [bar, used]));
});
@@ -219,7 +226,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
ui.set_style(ui.ctx().global_style());
ui.set_width(ROW2_W);
ui.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
content(app, ui, &t);
content(app, ui, &t, false);
if app.dialog.is_some() || app.colors.is_some() {
ui.close();
}
@@ -229,7 +236,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
/// The controls in two rows like Word's mini toolbar, reusing the ribbon's widgets so the bar
/// matches the ribbon exactly.
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens, preview_on_hover: bool) {
let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default();
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
// As on the ribbon, the buttons show alignment as seen on the page (#3579).
@@ -237,8 +244,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
.session
.doc
.para_at(&app.session.sel.focus)
.map(|p| {
let r = app.session.doc.styles.resolve_para(&p.props);
.map(|_| {
let r = wordcraft_engine::cmd::para::selection_para(&app.session);
r.align.visual(r.bidi)
})
.unwrap_or_default();
@@ -256,6 +263,7 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
let fams = app.previews.families();
let recent: Vec<String> = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect();
let prev = app.previews.font_preview_fn();
let _ = widgets::take_hover(ui.ctx());
let options = widgets::FontComboOptions {
id: "mini_font",
width: COMBO_FONT_W,
@@ -266,7 +274,16 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
tip: tl!("Font"),
shown_as: warning.as_deref(),
};
if let Some(f) = widgets::font_combo_with_recent_hint(ui, options) {
let picked = widgets::font_combo_with_recent_hint(ui, options);
if preview_on_hover
&& picked.is_none()
&& let Some(f) = widgets::take_hover(ui.ctx())
&& f != crate::widgets::ADD_FONT
&& f != crate::widgets::LOAD_SYSTEM_FONTS
{
app.canvas.command_preview = Some(("format.font".to_string(), json!({"name": app.previews.font_stored(&f)})));
}
if let Some(f) = picked {
let _ = if f == crate::widgets::ADD_FONT {
app.run("font.add", json!({}))
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
@@ -280,33 +297,34 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default();
let sizes: Vec<String> =
wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
if let Some(v) = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"))
let _ = widgets::take_hover(ui.ctx());
let picked = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"));
if preview_on_hover
&& picked.is_none()
&& let Some(x) = widgets::take_hover(ui.ctx()).and_then(|v| v.trim().parse::<f64>().ok())
{
app.canvas.command_preview = Some(("format.size".to_string(), json!({"size": x})));
}
if let Some(v) = picked
&& let Ok(x) = v.trim().parse::<f64>()
{
let _ = app.run("format.size", json!({"size": x}));
}
ui.add_space(GAP);
widgets::small(ui, app, "grow", None, "Increase Font Size", crate::widgets::ButtonAction::new("format.growFont", json!({}), false));
widgets::small(ui, app, "shrink", None, "Decrease Font Size", crate::widgets::ButtonAction::new("format.shrinkFont", json!({}), false));
hover_small(ui, app, "grow", "Increase Font Size", "format.growFont", false, preview_on_hover);
hover_small(ui, app, "shrink", "Decrease Font Size", "format.shrinkFont", false, preview_on_hover);
});
row(ui, app, &|ui, app| {
widgets::small(ui, app, "bold", None, "Bold", crate::widgets::ButtonAction::new("format.bold", json!({}), flag("bold")));
widgets::small(ui, app, "italic", None, "Italic", crate::widgets::ButtonAction::new("format.italic", json!({}), flag("italic")));
widgets::small(ui, app, "underline", None, "Underline", crate::widgets::ButtonAction::new("format.underline", json!({}), flag("underline")));
widgets::small(
ui,
app,
"strike",
None,
"Strikethrough",
crate::widgets::ButtonAction::new("format.strikethrough", json!({}), flag("strike")),
);
hover_small(ui, app, "bold", "Bold", "format.bold", flag("bold"), preview_on_hover);
hover_small(ui, app, "italic", "Italic", "format.italic", flag("italic"), preview_on_hover);
hover_small(ui, app, "underline", "Underline", "format.underline", flag("underline"), preview_on_hover);
hover_small(ui, app, "strike", "Strikethrough", "format.strikethrough", flag("strike"), preview_on_hover);
sep(ui, t);
let hl = app.canvas.last_highlight.clone();
let (icon, sw, color) = highlight_button_appearance(&hl);
widgets::split(
let main = widgets::split(
ui,
app,
icon,
@@ -314,12 +332,20 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
crate::widgets::ButtonAction::new("format.highlight", json!({"color": color}), false),
sw,
|ui, app| {
let _ = widgets::take_hover(ui.ctx());
let in_effect = st.get("highlight").and_then(Value::as_str).unwrap_or("none").to_string();
widgets::set_current_pick(ui.ctx(), Some(in_effect));
highlight_menu(ui, app);
widgets::set_current_pick(ui.ctx(), None);
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
app.canvas.command_preview = Some(("format.highlight".to_string(), json!({"color": c})));
}
},
);
preview_on_hover_if(app, &main, "format.highlight", json!({"color": color}), preview_on_hover);
let fc = app.canvas.last_font_color.clone();
let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32);
widgets::split(
let main = widgets::split(
ui,
app,
"fontcolor",
@@ -327,26 +353,50 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
crate::widgets::ButtonAction::new("format.color", json!({"color": fc}), false),
sw,
|ui, app| {
let _ = widgets::take_hover(ui.ctx());
widgets::set_current_pick(ui.ctx(), st.get("color").and_then(Value::as_str).map(str::to_string));
font_color_menu(ui, app);
widgets::set_current_pick(ui.ctx(), None);
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
app.canvas.command_preview = Some(("format.color".to_string(), json!({"color": c})));
}
},
);
preview_on_hover_if(app, &main, "format.color", json!({"color": fc}), preview_on_hover);
sep(ui, t);
use wordcraft_doc::Align as A;
widgets::small(ui, app, "alignLeft", None, "Align Left", crate::widgets::ButtonAction::new("para.alignLeft", json!({}), align == A::Left));
widgets::small(ui, app, "alignCenter", None, "Center", crate::widgets::ButtonAction::new("para.alignCenter", json!({}), align == A::Center));
widgets::small(
ui,
app,
"alignRight",
None,
"Align Right",
crate::widgets::ButtonAction::new("para.alignRight", json!({}), align == A::Right),
);
widgets::small(ui, app, "justify", None, "Justify", crate::widgets::ButtonAction::new("para.justify", json!({}), align == A::Justify));
hover_small(ui, app, "alignLeft", "Align Left", "para.alignLeft", align == A::Left, preview_on_hover);
hover_small(ui, app, "alignCenter", "Center", "para.alignCenter", align == A::Center, preview_on_hover);
hover_small(ui, app, "alignRight", "Align Right", "para.alignRight", align == A::Right, preview_on_hover);
hover_small(ui, app, "justify", "Justify", "para.justify", align == A::Justify, preview_on_hover);
});
}
/// A bar button that, while hovered, makes the page show its command run on the selection. The
/// preview runs on a copy, so the document, its undo history and the selection stay as they are.
fn hover_small(ui: &mut egui::Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, checked: bool, preview: bool) {
let action = crate::widgets::ButtonAction::new(id, json!({}), checked);
let r = widgets::small(ui, app, icon, None, tip, action);
preview_on_hover_if(app, &r, id, json!({}), preview);
}
/// Previews command `id` with `params` while its button `r` is hovered and not pressed. After a
/// click the preview stays off until the pointer leaves the button: a toggle would otherwise
/// preview its other state under the pointer and look as if it flashed.
fn preview_on_hover_if(app: &mut WordApp, r: &egui::Response, id: &str, params: Value, preview: bool) {
let hold = r.id.with("hold_preview");
if r.clicked() {
r.ctx.data_mut(|d| d.insert_temp(hold, true));
} else if !r.hovered() {
r.ctx.data_mut(|d| d.remove::<bool>(hold));
}
let held = r.ctx.data(|d| d.get_temp::<bool>(hold)).unwrap_or(false);
if preview && !held && r.hovered() && !r.is_pointer_button_down_on() && !r.clicked() && widgets::enabled(app, id) {
app.canvas.command_preview = Some((id.to_string(), params));
}
}
/// A hairline divider between the bar's groups.
fn sep(ui: &mut egui::Ui, t: &Tokens) {
let (r, _) = ui.allocate_exact_size(Vec2::new(SEP_W, 20.0), Sense::hover());
@@ -370,6 +420,13 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
let Some(c) = h.rgb() else { continue };
let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click());
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2));
if resp.hovered() {
widgets::note_hover(ui.ctx(), h.ooxml().to_string());
}
if widgets::current_pick(ui.ctx()).is_some_and(|cur| cur.eq_ignore_ascii_case(&c.hex())) {
let accent = Tokens::get(ui.ctx()).accent;
ui.painter().rect_stroke(r.expand(1.0), 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
}
if resp.on_hover_text(tl!(h.name())).clicked() {
app.canvas.last_highlight = h.ooxml().to_string();
widgets::run_from_ribbon(app, "format.highlight", json!({"color": h.ooxml()}));
@@ -381,7 +438,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
}
});
let enabled = widgets::enabled(app, "format.highlight");
if ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).min_size(Vec2::new(200.0, 0.0))).clicked() {
let none_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "none");
let no_color = ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).selected(none_in_effect).min_size(Vec2::new(200.0, 0.0)));
if no_color.hovered() {
widgets::note_hover(ui.ctx(), "none".to_string());
}
if no_color.clicked() {
app.canvas.last_highlight = "none".into();
widgets::run_from_ribbon(app, "format.highlight", json!({"color": "none"}));
ui.close();
@@ -391,7 +453,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
/// Font colour: Automatic, the colour grid, Recent Colors and More Colors… (#606).
fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
if ui.selectable_label(false, "Automatic").clicked() {
let auto_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "auto");
let automatic = ui.selectable_label(auto_in_effect, "Automatic");
if automatic.hovered() {
widgets::note_hover(ui.ctx(), "auto".to_string());
}
if automatic.clicked() {
widgets::run_from_ribbon(app, "format.color", json!({"color": "auto"}));
ui.close();
return;
+289 -1
View File
@@ -198,6 +198,211 @@ fn format_state(h: &mut egui_kittest::Harness<'static, WordApp>) -> Value {
h.state_mut().session.run("format.state", &json!({})).unwrap()
}
/// Hovering an entry of the bar's size list previews that size; the pick is not made.
#[test]
fn hovering_a_size_in_the_bars_list_previews_it() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let (doc, size) = (h.state().session.doc.clone(), format_state(&mut h).get("size").cloned());
let viewport = h.state().canvas.viewport.unwrap();
let size_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.max_by(|a, b| a.min.x.total_cmp(&b.min.x))
.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();
h.event(egui::Event::PointerMoved(twenty));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.size".to_string(), json!({"size": 20.0}))));
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the 20 pt text");
assert_eq!(&h.state().session.doc, &doc);
assert_eq!(format_state(&mut h).get("size").cloned(), size);
}
/// Hovering a family of the bar's font list previews it; clicking it applies it.
#[test]
fn hovering_and_clicking_a_font_in_the_bars_list() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let doc = h.state().session.doc.clone();
let viewport = h.state().canvas.viewport.unwrap();
let font_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
.expect("the bar has a font box");
click_at(&mut h, pos2(font_box.max.x + 8.0, font_box.center().y));
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
h.event(egui::Event::PointerMoved(at));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(id, _)| id == "format.font"), "hovering a family previews the font");
assert_eq!(&h.state().session.doc, &doc);
click_at(&mut h, at);
assert_ne!(&h.state().session.doc, &doc, "clicking the family applies the font");
}
/// A picked family joins Recently Used Fonts when the list next opens, not in the frame after the
/// pick, when the list is still on screen and would shift under the pointer.
#[test]
fn a_picked_font_joins_the_recent_section_on_the_next_open() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let font_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
.expect("the bar has a font box");
let arrow = pos2(font_box.max.x + 8.0, font_box.center().y);
click_at(&mut h, arrow);
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
h.event(egui::Event::PointerMoved(at));
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.step();
h.step();
h.step();
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 0, "the list does not shift under the pointer");
assert_eq!(h.query_all_by_label("Add Font…").count(), 0, "picking a family closes the list");
steps(&mut h);
click_at(&mut h, arrow);
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 1, "the next open lists the family first");
}
/// Hovering the highlight and font colour buttons previews their last colour.
#[test]
fn hovering_the_colour_buttons_previews_their_colour() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
for (label, id) in [("Text Highlight Color", "format.highlight"), ("Font Color", "format.color")] {
let r = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
h.event(egui::Event::PointerMoved(r.center()));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(p, _)| p == id), "{label} previews {id}");
}
}
/// Hovering a colour of the bar's highlight list previews it on the selection.
#[test]
fn hovering_a_highlight_colour_previews_it() {
use egui_kittest::kittest::{By, Queryable};
let mut h = bar_over("brown fox");
let doc = h.state().session.doc.clone();
let [bar, _] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar is shown over a selection");
let arrow = h
.query_all(By::new().predicate(|n| n.raw_bounds().is_some_and(|r| ((r.x1 - r.x0) - 11.0).abs() < 0.5 && ((r.y1 - r.y0) - 22.0).abs() < 0.5)))
.map(|n| n.rect())
.filter(|r| bar.contains_rect(*r))
.min_by(|a, b| a.min.x.total_cmp(&b.min.x))
.expect("the bar has the Highlight split button");
click_at(&mut h, arrow.center());
let no_color = h.get_by_label("No Color").rect();
h.event(egui::Event::PointerMoved(pos2(no_color.min.x + 10.0, no_color.center().y - 30.0)));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(id, _)| id == "format.highlight"), "a swatch previews the highlight");
assert_eq!(&h.state().session.doc, &doc);
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None);
}
/// Hovering a bar button previews its command on the selection; leaving the bar restores the
/// page. The document, its selection and its revision stay as they were.
#[test]
fn hovering_a_bar_button_previews_it_on_the_selection() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let (doc, sel, rev) = (h.state().session.doc.clone(), h.state().session.sel.clone(), h.state().session.rev());
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold");
h.event(egui::Event::PointerMoved(bold.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))));
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the bold text");
assert_eq!((&h.state().session.doc, &h.state().session.sel, h.state().session.rev()), (&doc, &sel, rev));
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(false)));
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None);
assert_eq!(crate::canvas::page_doc(h.state()), &doc, "off the bar the page is as it was");
// Alignment moves the previewed selection. The toolbar must stay anchored to the live
// selection so the hovered button remains under the pointer until the pointer leaves.
let anchor = h.state().canvas.mini_anchor.unwrap();
let align_right = h.get_all_by_label("Align Right").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Align Right");
h.event(egui::Event::PointerMoved(align_right.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("para.alignRight".to_string(), json!({}))));
assert_eq!(h.state().canvas.mini_anchor, Some(anchor), "the alignment preview must not move the toolbar");
}
/// While a preview is shown the page holds its scroll position, though the previewed text
/// reflows and the caret would be scrolled into view.
#[test]
fn the_page_holds_its_place_while_a_preview_is_shown() {
let mut h = bar_over("brown fox");
h.state_mut().run("caret.docStart", json!({})).unwrap();
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
steps(&mut h);
let page = h.state().canvas.viewport.unwrap();
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
phase: egui::TouchPhase::Start,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
steps(&mut h);
let held = h.state().canvas.scroll_offset;
for _ in 0..4 {
h.state_mut().canvas.command_preview = Some(("format.size".to_string(), json!({"size": 72.0})));
h.state_mut().canvas.scroll_to_caret = true;
h.step();
}
assert!(h.state().canvas.command_previewed.is_some());
assert_eq!(h.state().canvas.scroll_offset, held, "the preview does not move the page");
steps(&mut h);
assert_eq!(h.state().canvas.preview_scroll, None, "the hold ends with the preview");
}
/// With no preview shown the bar's anchor follows the selection as the page scrolls.
#[test]
fn the_bar_anchor_follows_the_selection_as_the_page_scrolls() {
let mut h = bar_over("brown fox");
h.state_mut().run("caret.docStart", json!({})).unwrap();
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
steps(&mut h);
let anchored = h.state().canvas.mini_anchor.unwrap();
let page = h.state().canvas.viewport.unwrap();
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, 120.0),
phase: egui::TouchPhase::Start,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
steps(&mut h);
assert_ne!(h.state().canvas.mini_anchor, Some(anchored), "the toolbar anchor follows the live selection as the viewport scrolls");
}
/// #2959: formatting must be available alongside the text's right-click menu, using the
/// selection that was clicked. Its lists must remain usable inside the context popup.
#[test]
@@ -219,6 +424,11 @@ fn context_menu_toolbar_formats_the_selection_and_opens_its_size_list() {
assert!(bar_layer(&h).is_none(), "the standalone selection toolbar must not double up");
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the context menu has Bold");
let doc = h.state().session.doc.clone();
h.event(egui::Event::PointerMoved(bold.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None, "the context toolbar does not start the hidden floating bar's preview repaint loop");
assert_eq!(&h.state().session.doc, &doc, "hovering a context-menu control does not preview a document change");
click_at(&mut h, bold.center());
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
assert_eq!(h.state().session.sel, selection);
@@ -602,7 +812,7 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
}
egui::Area::new(egui::Id::unique("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| {
let t = Tokens::get(ui.ctx());
content(app, ui, &t);
content(app, ui, &t, true);
});
},
a,
@@ -626,3 +836,81 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
assert!(!is_marked(unlit), "rtl={rtl} {cmd:?}: {unlit} is not marked");
}
}
#[test]
fn the_alignment_buttons_align_the_paragraph() {
use egui_kittest::kittest::Queryable;
for (label, align) in [("Center", "center"), ("Align Right", "right"), ("Justify", "both"), ("Align Left", "left")] {
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let b = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
h.event(egui::Event::PointerMoved(b.center()));
steps(&mut h);
click_at(&mut h, b.center());
let got = h.state().session.doc.para_at(&h.state().session.sel.focus).map(|p| format!("{:?}", p.props.align)).unwrap_or_default();
assert!(got.to_lowercase().contains(align) || (align == "both" && got.to_lowercase().contains("justif")), "{label}: {got}");
}
}
/// After a click the button's preview stays off while the pointer is on it, so a toggle does not
/// flash its other state; leaving and coming back previews again.
#[test]
fn a_clicked_button_holds_its_preview_until_the_pointer_leaves() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let size = format_state(&mut h).get("size").cloned();
let grow = h.get_all_by_label("Increase Font Size").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Grow").center();
h.event(egui::Event::PointerMoved(grow));
steps(&mut h);
assert!(h.state().canvas.command_previewed.is_some(), "hovering previews");
click_at(&mut h, grow);
steps(&mut h);
assert_ne!(format_state(&mut h).get("size").cloned(), size, "the click grew the text");
assert_eq!(h.state().canvas.command_previewed, None, "no preview of the next step under the pointer");
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
h.event(egui::Event::PointerMoved(grow));
steps(&mut h);
assert!(h.state().canvas.command_previewed.is_some(), "coming back previews again");
}
/// After a click the preview stays still while the pointer is on the button. Leaving and coming
/// back previews the undo: Bold over bold text shows it unbolded.
#[test]
fn coming_back_to_a_toggle_previews_its_undo() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold").center();
click_at(&mut h, bold);
steps(&mut h);
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
assert_eq!(h.state().canvas.command_previewed, None, "the page holds still after the click");
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
h.event(egui::Event::PointerMoved(bold));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))), "coming back previews the undo");
let doc = h.state().session.doc.clone();
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the text unbolded");
}
/// A selection that ends at the next paragraph's start (Select Paragraph, a triple-click) shows
/// the alignment of the paragraph it covers, not the next one's.
#[test]
fn a_selected_paragraph_shows_its_own_alignment() {
let mut h = bar_over("brown fox");
h.state_mut().session = wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("First paragraph\nSecond paragraph"));
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 2);
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(0, 2);
h.state_mut().run("para.alignRight", json!({})).unwrap();
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 0);
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(1, 0);
steps(&mut h);
let s = &h.state().session;
let focus_align = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props).align);
assert_eq!(focus_align, Some(wordcraft_doc::Align::Left), "the next paragraph is left aligned");
let r = wordcraft_engine::cmd::para::selection_para(s);
assert_eq!(r.align, wordcraft_doc::Align::Right, "the buttons read the selected paragraph");
}
+15 -7
View File
@@ -530,6 +530,14 @@ fn shows_pixels(d: &ObjectDrag) -> bool {
(d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move || (d.drag.grab == Grab::Rotate && d.object.text_box.is_none())
}
/// The pixels-per-page-point for a `w` x `h` preview, lowered so neither side exceeds the GPU's
/// `max_side` (the texture is painted into the object's rect, so a smaller one is only stretched).
fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
let room = (max_side.min(wordcraft_render::MAX_SIDE as usize).max(2) - 1) as f32;
let px = if px.is_finite() { px } else { 1.0 };
px.min(room / w.max(h).max(1.0))
}
/// `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<egui::TextureHandle> {
@@ -539,13 +547,13 @@ fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit
opts.dark = crate::canvas::dark_page(app);
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,
wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h),
scale * ctx.pixels_per_point(),
&opts,
);
let max_side = ctx.input(|i| i.max_texture_side);
let px = preview_scale(scale * ctx.pixels_per_point(), b.w, b.h, max_side);
let img = wordcraft_render::render_items(&app.session.doc, &items, wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h), px, &opts);
// A texture over the device limit panics wgpu outside `Session::run`; fall back to the outline.
if img.width as usize > max_side || img.height as usize > max_side {
return None;
}
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))
}
+61
View File
@@ -914,3 +914,64 @@ fn rotation_drag_keeps_text_boxes_outline_only_until_their_text_can_rotate() {
assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, 90.0);
assert_eq!(h.state().session.undo_depth(), depth + 1);
}
#[test]
fn drag_preview_scale_stays_within_the_gpu_texture_limit() {
// A 14,400 pt picture at 2x on a GPU limited to 8,192 px.
let px = preview_scale(2.0, 14_400.0, 100.0, 8192);
assert!((14_400.0f32 * px).ceil() <= 8192.0);
assert_eq!(preview_scale(2.0, 100.0, 50.0, 8192), 2.0);
assert_eq!(preview_scale(2.0, 0.0, 0.0, 8192), 2.0);
assert!((100.0f32 * preview_scale(2.0, 100.0, 50.0, 1)).ceil() <= 1.0);
for bad in [f32::NAN, f32::INFINITY, 0.0] {
assert!((100.0f32 * preview_scale(bad, 100.0, 100.0, 0)).ceil() <= 2.0);
}
}
/// A large picture's drag preview remains a legal device texture.
#[test]
fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
use crate::table_resize::tests::{H, button, steps};
use egui::Event;
let image = image::RgbaImage::from_pixel(16, 16, image::Rgba([30, 120, 210, 255]));
let mut bytes = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
let picture = InlineObject::Image {
media: "large-preview-test".into(),
w: 14_400.0,
h: 200.0,
alt: String::new(),
float: wordcraft_doc::para::Float { wrap: Wrap::InFrontOfText, x: 100.0, y: 100.0, ..Default::default() },
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let (mut app, _) = app_with(picture);
app.session.doc.media.insert("large-preview-test".into(), Arc::new(bytes));
app.session.touch();
app.run("view.commentsPane", json!({"value": false})).unwrap();
app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, OBJ.len_utf8())})).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);
},
app,
);
steps(&mut h, 6);
let hit = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
let max_side = h.ctx.input(|i| i.max_texture_side);
assert!(hit.bounds().w * h.ctx.pixels_per_point() > max_side as f32, "unclamped picture exceeds the texture limit");
let at = crate::canvas::page_to_screen(h.state_mut(), hit.page, hit.rect.x + 10.0, hit.rect.y + 10.0).expect("the picture center is visible");
h.event(Event::PointerMoved(at));
button(&h, at, true);
h.step();
let moved = at + egui::vec2(24.0, 0.0);
h.event(Event::PointerMoved(moved));
steps(&mut h, 2);
let drag = h.state().canvas.obj_drag.as_ref().expect("picture drag started");
let preview = drag.preview.as_ref().expect("large picture preview is generated");
let [width, height] = preview.size();
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
}
+9 -1
View File
@@ -59,8 +59,9 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
app,
crate::widgets::MenuButtonOptions::new("smartArtColors", Some("Change\nColors"), "Change Colors", true, ""),
|ui, app| {
let custom = g.has_custom_colors();
for colors in SmartArtColors::ALL {
let on = g.colors == colors;
let on = g.colors == colors && !custom;
let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0)));
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme);
@@ -69,6 +70,13 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
ui.close();
}
}
// Active while items carry their own colours; set in the Text Pane.
let resp = ui.add_enabled(custom, egui::Button::new(tl!("Custom")).selected(custom).min_size(vec2(200.0, 24.0)));
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
crate::dialogs_smart_art::custom_swatches(ui.painter(), sw, &g, &theme);
if !custom {
resp.on_disabled_hover_text(tl!("Set the colors in the Text Pane to use custom colors."));
}
},
);
});
+1 -1
View File
@@ -169,7 +169,7 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
// The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start).
let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default();
let rtl = rp.as_ref().is_some_and(|r| r.bidi);
+1 -1
View File
@@ -121,7 +121,7 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
let Some(rp) = rp else { return };
// #654: header + two rows of 20 px fields fit the content height above the group title
// (1.5 pt between rows; 3 ran 2.5 pt into it); labels and fields sit on shared columns,
+1 -1
View File
@@ -17,13 +17,13 @@ pub use buttons::{
label_line, menu_button, menu_enabled, small, split,
};
pub(crate) use buttons::{bg, run_from_ribbon};
pub(crate) use combo::swatch;
pub use combo::{
ADD_FONT, COMBO_PREVIEW_ROW_H, DrawnState, FontComboOptions, LOAD_SYSTEM_FONTS, color_grid, combo, combo_hint, combo_list, drawn_state,
font_combo, font_combo_with_recent, font_combo_with_recent_hint, font_matches,
};
#[cfg(test)]
use combo::{Row, highlight_row, section_row, section_rows};
pub(crate) use combo::{current_pick, note_hover, set_current_pick, swatch, take_hover};
pub use groups::{command_icon, command_menu, customize_menu_items, group, group_titled, row};
pub use icon_buttons::{
dialog_tabs, icon_button, icon_menu_item, icon_only_button, icon_text_button, icon_text_button_width, icon_text_menu_button, primary_button,
+3 -2
View File
@@ -173,7 +173,7 @@ pub fn split(
action: ButtonAction<'_>,
swatch: Option<Color32>,
menu: impl FnOnce(&mut Ui, &mut WordApp),
) {
) -> Response {
let ButtonAction { id, params, checked } = action;
let tip = tl!(tip);
let t = Tokens::get(ui.ctx());
@@ -205,12 +205,13 @@ pub fn split(
// (#3172).
aresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, &arrow_title));
if !on {
return;
return resp;
}
crate::rtl::menu(&aresp).show(|ui| {
ui.set_min_width(180.0);
menu(ui, app);
});
resp
}
/// Whether a menu button whose menu is about command `id` is on ("" = always).
+112 -11
View File
@@ -39,6 +39,44 @@ pub fn color_grid(ui: &mut Ui, theme: &[wordcraft_doc::Rgb]) -> Option<String> {
picked
}
fn hover_key() -> egui::Id {
egui::Id::unique("wc_pick_hover")
}
/// Records the list entry or colour the pointer is over in this pass (one per pass; the last
/// drawn wins). [`take_hover`] reads it.
pub(crate) fn note_hover(ctx: &egui::Context, value: String) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(hover_key(), (pass, value)));
}
/// The entry noted by [`note_hover`] in this pass, if any; it is read once. Callers take before
/// drawing a list too, to drop what another widget noted.
pub(crate) fn take_hover(ctx: &egui::Context) -> Option<String> {
let pass = ctx.cumulative_pass_nr();
let seen = ctx.data_mut(|d| d.remove_temp::<(u64, String)>(hover_key()));
seen.filter(|(p, _)| *p == pass).map(|(_, v)| v)
}
fn current_key() -> egui::Id {
egui::Id::unique("wc_pick_current")
}
/// The colour (hex, or a name the menu uses) a palette marks as the one in effect; `None` marks
/// nothing. The caller sets it before drawing a menu and clears it after.
pub(crate) fn set_current_pick(ctx: &egui::Context, current: Option<String>) {
ctx.data_mut(|d| match current {
Some(c) => {
d.insert_temp(current_key(), c);
}
None => d.remove::<String>(current_key()),
});
}
pub(crate) fn current_pick(ctx: &egui::Context) -> Option<String> {
ctx.data(|d| d.get_temp::<String>(current_key()))
}
pub(crate) fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(15.0, 15.0), Sense::click());
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
@@ -49,6 +87,12 @@ pub(crate) fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
Stroke::new(if resp.hovered() { 1.5 } else { 0.5 }, if resp.hovered() { t.accent } else { t.border_strong }),
egui::StrokeKind::Inside,
);
if resp.hovered() {
note_hover(ui.ctx(), c.hex());
}
if current_pick(ui.ctx()).is_some_and(|cur| cur.eq_ignore_ascii_case(&c.hex())) {
ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);
}
resp.on_hover_text(format!("#{}", c.hex())).clicked()
}
@@ -377,11 +421,22 @@ fn combo_impl(ui: &mut Ui, options: ComboOptions<'_>) -> Option<String> {
menu_field.rect = resp.rect.union(ar);
let menu = craft_ui::combo::Menu::new(ui, &menu_field).close_behavior(close).height(420.0);
let row_h = if preview.is_some() { COMBO_PREVIEW_ROW_H } else { ui.spacing().interact_size.y };
// The recent section keeps its order while the list stays open: a pick moves the family to
// the top for the next time the list opens, not under the pointer.
let frozen_id = search_id.with("recent_frozen");
let menu_recent: Vec<String> = ui.data(|d| d.get_temp(frozen_id)).unwrap_or_else(|| recent.to_vec());
let item = |k: usize| items.get(k);
let row = |k: usize| section_row(k, recent, &item);
let total = section_rows(recent.len(), items.len());
let row = |k: usize| section_row(k, &menu_recent, &item);
let total = section_rows(menu_recent.len(), items.len());
let list = |ui: &mut Ui, range: std::ops::Range<usize>, ()| {
draw_rows(ui, RowDrawOptions { width, current, range, row: &row, highlight: None, preview, row_height: row_h })
let picked = draw_rows(
ui,
RowDrawOptions { width, current, range, row: &row, highlight: None, preview, row_height: row_h, recent_rows: menu_recent.len() },
);
if picked.is_some() {
ui.close();
}
picked
};
let footer = |ui: &mut Ui| {
let mut picked = None;
@@ -402,6 +457,13 @@ fn combo_impl(ui: &mut Ui, options: ComboOptions<'_>) -> Option<String> {
} else {
menu.show_rows_with_header(|_| (), row_h, |()| total, list)
};
ui.data_mut(|d| {
if picked.is_some() {
d.insert_temp(frozen_id, menu_recent.clone());
} else {
d.remove::<Vec<String>>(frozen_id);
}
});
if let Some(v) = picked.flatten() {
out = Some(v);
}
@@ -425,7 +487,10 @@ fn combo_impl(ui: &mut Ui, options: ComboOptions<'_>) -> Option<String> {
.show(ui, |ui| {
let item = |k: usize| matches.get(k).and_then(|&i| items.get(i));
let row = |k: usize| section_row(k, recent_shown, &item);
list_rows(ui, RowListOptions { width, current, n, row: &row, highlight: Some(lit), scroll, preview })
list_rows(
ui,
RowListOptions { width, current, n, row: &row, highlight: Some(lit), scroll, preview, recent_rows: recent_shown.len() },
)
})
.inner
});
@@ -467,7 +532,20 @@ pub const COMBO_PREVIEW_ROW_H: f32 = 24.0;
/// family and each preview loads that font, so building all rows loaded every font on the system
/// (gigabytes on large font collections) and froze the app (#121).
pub fn combo_list(ui: &mut Ui, width: f32, current: &str, items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>) -> Option<String> {
list_rows(ui, RowListOptions { width, current, n: items.len(), row: &|k| items.get(k).map(Row::Item), highlight: None, scroll: None, preview }).0
list_rows(
ui,
RowListOptions {
width,
current,
n: items.len(),
row: &|k| items.get(k).map(Row::Item),
highlight: None,
scroll: None,
preview,
recent_rows: 0,
},
)
.0
}
/// A row of a combo's list: a dim section heading nothing picks, or an entry.
@@ -536,13 +614,19 @@ struct RowDrawOptions<'rows, 'text> {
highlight: Option<usize>,
preview: Option<&'rows dyn Fn(&mut Ui, &str) -> Response>,
row_height: f32,
/// How many recent families head the list (0 without the section).
recent_rows: usize,
}
fn draw_rows(ui: &mut Ui, options: RowDrawOptions<'_, '_>) -> Option<String> {
let RowDrawOptions { width, current, range, row, highlight, preview, row_height: row_h } = options;
let RowDrawOptions { width, current, range, row, highlight, preview, row_height: row_h, recent_rows } = options;
let mut out = None;
let fill = Tokens::get(ui.ctx()).checked;
let tokens = Tokens::get(ui.ctx());
let (fill, accent) = (tokens.checked, tokens.accent);
ui.set_min_width(width + 60.0);
// A family in Recently Used is marked there only: marked again under All Fonts it would look as
// if two were chosen.
let current_is_recent = (1..=recent_rows).any(|k| matches!(row(k), Some(Row::Item(r)) if r == current));
for k in range {
let it = match row(k) {
Some(Row::Item(it)) => it,
@@ -559,9 +643,25 @@ fn draw_rows(ui: &mut Ui, options: RowDrawOptions<'_, '_>) -> Option<String> {
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), row_h));
ui.painter().rect_filled(r, 3.0, fill);
}
p(ui, it).clicked()
let in_all_fonts = recent_rows > 0 && k > recent_rows.saturating_add(1);
if it == current && !(in_all_fonts && current_is_recent) {
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), row_h));
ui.painter().rect_filled(r, 3.0, fill);
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(3.0, row_h)), 1.5, accent);
}
let resp = p(ui, it);
if resp.hovered() {
note_hover(ui.ctx(), it.clone());
}
resp.clicked()
}
None => {
let resp = ui.selectable_label(if highlight.is_some() { lit } else { it == current }, it.as_str());
if resp.hovered() {
note_hover(ui.ctx(), it.clone());
}
resp.clicked()
}
None => ui.selectable_label(if highlight.is_some() { lit } else { it == current }, it.as_str()).clicked(),
};
if clicked {
out = Some(it.clone());
@@ -581,10 +681,11 @@ struct RowListOptions<'rows, 'text> {
highlight: Option<usize>,
scroll: Option<(f32, f32)>,
preview: Option<&'rows dyn Fn(&mut Ui, &str) -> Response>,
recent_rows: usize,
}
fn list_rows(ui: &mut Ui, options: RowListOptions<'_, '_>) -> (Option<String>, f32, f32) {
let RowListOptions { width, current, n, row, highlight, scroll, preview } = options;
let RowListOptions { width, current, n, row, highlight, scroll, preview, recent_rows } = options;
let mut out = None;
let row_h = if preview.is_some() { COMBO_PREVIEW_ROW_H } else { ui.spacing().interact_size.y };
let desired_h = (n as f32 * (row_h + ui.spacing().item_spacing.y)).min(420.0);
@@ -607,7 +708,7 @@ fn list_rows(ui: &mut Ui, options: RowListOptions<'_, '_>) -> (Option<String>, f
area = area.vertical_scroll_offset(offset.max(0.0));
}
let shown = area.show_rows(ui, row_h, n, |ui, range| {
if let Some(v) = draw_rows(ui, RowDrawOptions { width, current, range, row, highlight, preview, row_height: row_h }) {
if let Some(v) = draw_rows(ui, RowDrawOptions { width, current, range, row, highlight, preview, row_height: row_h, recent_rows }) {
out = Some(v);
}
});
+9
View File
@@ -1,4 +1,5 @@
# File-format parity with Microsoft Word
> **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves.
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around tLine truncated
@@ -289,6 +290,9 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
## Revision history
> 2026-11-03 · Section `w:chapStyle` values preserve the full unsigned-byte range, including 10, while values above 255 are rejected.
| 2026-10-30 | trivial | DOCX section metadata now preserves independent printer trays, section endnote suppression, chapter numbering settings, and footnote columns through native JSON and repeated save/reopen |
> 2026-10-28 · Charts and SmartArt: text the font lacks (Chinese axis labels and legends) is drawn in a fallback face instead of boxes, one face per run as in body text (#4720, `layout::tests::chart_text_the_font_lacks_is_drawn_in_a_fallback_face`).
> 2026-10-25 · Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`).
@@ -297,6 +301,9 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves |
| 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
| 2026-11-04 | trivial | Picture brightness, contrast and transparency: DrawingML `a:lum` and `a:alphaModFix`, VML `gain`/`blacklevel` (Washout watermarks) read, drawn on screen and in PDF as Word does (PDF test reads the embedded pixels back), and written as DrawingML; Word 2003 XML writes brightness and contrast as `gain`/`blacklevel` (#1711, #1766) |
| 2026-11-02 | trivial | ODT form controls: one hanging from the baseline (as LibreOffice places those it makes) was saved with no placement, which LibreOffice puts at the line's top; its place on the line is kept (`ContentControl::form_raise`) and written, standing on the baseline when it had none. A multi-line text field was saved without LibreOffice's `MultiLine` property, so it showed one centred line; it says so, and text fields have LibreOffice's flat border. ODT custom shapes: a shape drawn from its own geometry was saved as WordCraft's path of it, and a preset WordCraft draws as a rectangle (a cloud) as a rectangle; the geometry as read (type, path, equations, handles) is kept (`ShapeExtra::odf_geometry`) and written back, mirrored as the shape is now, until the shape is made another kind. The forms document round-trips pixel-identical (from 2,949), the shape presets to 1,220 (from 2,467; what's left is LibreOffice's hairline outline, which WordCraft keeps as 0.5 pt) (`forms::controls_keep_their_place_on_the_line_and_a_text_area_its_lines`, `custom_geometry::a_custom_shapes_geometry_is_written_back_as_it_was_read`, `engine::tests::changing_a_shape_drops_the_geometry_kept_from_a_file`) |
| 2026-11-02 | trivial | ODT, found with LibreOffice's style templates: an in-line frame placed against the text (`style:vertical-rel="text"` or `"char"`: `middle`, `top`, `bottom`) stood on the baseline; it's raised or lowered against the text's height from its size (measured in LibreOffice: `middle` centres it on the text). An in-line picture or chart was saved with no placement, which LibreOffice puts at the line's top (measured); it's saved standing on the baseline, as WordCraft and Word show it. Two templates round-trip with a third to a half of their difference (`floats::an_inline_frame_placed_against_the_text_is_raised_from_its_size`, `floats::an_inline_picture_is_saved_standing_on_the_baseline`). Left in them: template instantiation (LibreOffice refreshes a template's fixed dates and fills its sender fields from the user's data when opening it), a first-page footer's frame placed against the page content, and a page background picture centred in the margins rather than on the page |
@@ -617,3 +624,5 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
| 2026-10-28 | trivial | RTF import: pictures stored as Windows Metafiles (`\wmetafile`) or Enhanced Metafiles (`\emfblip`) are kept as WMF/EMF media instead of dropped, and RTF export writes WMF/EMF pictures back as `\wmetafile8`/`\emfblip` instead of dropping them (`rtf::tests::metafile_pictures_are_imported`, #4259) |
| 2026-11-01 | trivial | Markdown export: a list inside a block quote keeps its `>` marker on every line (nested levels and continuation lines included), so the list no longer moves out of the quote when the document is saved as Markdown, also after a .docx round trip |
| 2026-11-03 | trivial | Opening a password-protected file named on the command line shows the Password box also when a chosen region replaced the first blank document (the window followed the replacement only on the first frame, which dropped the box, #5299) |
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
+12 -1
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (#3 narrowed: a Wayland clipboard picture is size-checked before it is decoded); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches suLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** trivial (#3 narrowed: a Wayland clipboard picture is size-checked before it is decoded); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` defaults true and is written in schema order); previously trivial (ink page lookups reuse scanned paragraph prefixes, #3614); previously trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); pLine truncated
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no lonLine truncated
Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md).
@@ -141,6 +141,8 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232):
- AutoFormat's `*bold*` and `_italic_` don't convert when a closing bracket or quote follows the closing mark (`(*see note*)`): the trigger checks only space and `,.;:!?` (#4098).
- The ribbon's Accept and Reject buttons only open their menus; Word's main half runs Accept (Reject) and Move to Next. The ribbon needs a big split button (Paste needs one too) (#4205).
Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate.
Recipient label generation (#4880) now includes all selected recipients across as many sheets as needed, retaining filtered/sorted order and the chosen product geometry. This fixes the one-sheet omission; Update Labels and Finish & Merge template behavior remain separate, and gap 21 remains open. Readiness estimates are unchanged.
File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and left/right-bound booklets (#641). Preview and print PDF share the sheet plan, selected pages, parity, copy padding and markup geometry. Custom-range changes reset the preview to the first sheet; Print Markup invalidates preview layout and textures. Native jobs omit link annotations as required by upstream #4734, while saved print PDFs keep their remapped links. Native printing submits copies to the spooler without multiplying them in the PDF. Ordinary PDF export remains single-page by default. Translations are reviewed separately in craft-i18n#38. Physical-printer qualification and driver-specific options remain unverified.
@@ -149,6 +151,8 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
## Revision history
> 2026-11-03 · Section `w:chapStyle` values now preserve the complete unsigned-byte range, including 10; values above 255 are rejected.
> 2026-11-03 · Gap 11: the Arabic dialogs, tables and galleries read right to left (rtl::Table); readiness unchanged.
>
> 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged.
@@ -161,8 +165,13 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
| 2026-11-05 | trivial | DOCX preserves `w:adjustRightInd` with its true default and writes it in schema order; grid layout remains open |
| 2026-10-28 | trivial | Ink page lookups reuse scanned paragraph prefixes, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
| 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged |
| 2026-11-04 | trivial | #19: ODT settings for what the standard leaves open removed: one choice for each case |
| 2026-11-04 | trivial | #19: ODT element audit; files from other applications checked |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
| 2026-11-01 | trivial | #19 narrowed: ODT read and written nearly whole on this branch (styles, tables, notes, comments, tracked changes including formatting, fields, indexes and marks, drawings, charts, embedded objects, forms, ruby, password protection, flat ODT), checked against LibreOffice; what's left listed; estimate 18–27 h → 10–16 h |
| 2026-11-01 | trivial | #10 narrowed: text in a turned text box is drawn turned with it on screen and in PNG and PDF (upside down when flipped across, never mirrored); editing it still goes by its upright text; estimate unchanged |
| 2026-10-29 | trivial | Print Markup balloon anchor discovery no longer repeats paragraph-prefix scans (#3614); readiness estimates unchanged |
@@ -418,6 +427,8 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
| 2026-10-29 | trivial | #15 narrowed: a Word bibliography source with a corporate author keeps it (read, saved, cited and listed whole), and Create Source has the Corporate Author box (#4848) |
| 2026-10-29 | trivial | Table Properties: row, column and cell settings apply to the selected rows, columns and cells, not only the caret's (#4857); no gap entry tracked it |
| 2026-10-28 | trivial | Gap 19 narrowed: RTF reads and writes WMF and EMF pictures, chosen by their bytes (#4259); ranking unchanged |
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
## Layout validation fonts
+3 -1
View File
@@ -1,6 +1,6 @@
# Hardware parity with Microsoft Word
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -21,12 +21,14 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Touch screens and touchpads: pinch zoom, inertial scroll | ✅ | ✅ pinch (#179) | ✅ | ✅ inertial scroll (#252) | ✅ | 85% | 0–1 |
| Scanners/cameras (Insert from device / Continuity Camera on Mac) | ✅ | ❌ | ❌ | ❌ | ❌ | 0% | 2–4 |
Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and left/right-bound booklets (#641). Preview and print PDF share the sheet plan, selected pages, parity, copy padding and markup geometry. Custom-range changes reset the preview to the first sheet; Print Markup invalidates preview layout and textures. Native jobs omit link annotations as required by upstream #4734, while saved print PDFs keep their remapped links. Native printing submits copies to the spooler without multiplying them in the PDF. Ordinary PDF export remains single-page by default. Translations are reviewed separately in craft-i18n#38. Physical-printer qualification and driver-specific options remain unverified.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-28 | trivial | Ink page lookup work, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
| 2026-11-01 | trivial | N-up and booklet print imposition (#641): integrate current print-job link handling (#4734), preserve markup preview and sheet/copy planning; readiness estimates unchanged. |
| 2026-10-30 | trivial | Only the PDF sent to the printer leaves out link annotations; Save these pages as PDF (`file.printPdf`) keeps them (#4734) |
| 2026-10-29 | trivial | A document with links prints: the print job's PDF leaves out link annotations, which the spooler refused to fit to the paper (#4734) |
+10 -1
View File
@@ -1,6 +1,6 @@
# Layout and pagination parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, withouLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** trivial (PR #4545: eligible cell floats keep in-bounds offsets; overflow confinement retains the front-of-text, turned-cell and native-acceptance limits); previously trivial (MATH construction reuse, #3630; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, ThaLine truncated
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
@@ -263,6 +263,10 @@ new mixed-style emoji and mixed bundled-font combining-mark controls cover LTR/b
hit stops, full selection, and narrow/overwide line wrapping. Independent review and the full
layout suite are pending; this is not a new Word/native-IME acceptance claim.
MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate.
The front-of-text opt-out is retained from the earlier implementation; this patch does not establish native Word equivalence between front-of-text and behind-text pictures. Synthetic placement tests cover the two paths, while a new native comparison of those wrap modes remains open. Turned-cell layouts disable horizontal cell confinement because their frame x axis becomes page y.
## Revision history
> 2026-11-01 · Text in a turned text box is drawn turned with it, about its centre, on screen and in PNG and PDF export; flipped across, upside down; flipped side to side, still readable (as Word draws it). Clicks and the caret inside a turned box still go by its upright text (`layout::tests::a_turned_text_box_turns_its_text_with_it`).
@@ -283,6 +287,9 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim.
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
| 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged |
| 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box |
| 2026-11-01 | trivial | SEQ, STYLEREF and REF fields show fresh results in output; readiness estimates unchanged |
| 2026-10-29 | trivial | Print Markup balloon anchor discovery walks paragraph objects once (#3614); readiness estimates unchanged |
@@ -324,6 +331,8 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim.
| 2026-10-23 | trivial | Continuous sections running off a page go onto the next; empty marks after a section mark take a line until the page is full, measured in Word (#3907) |
| 2026-10-27 | trivial | Empty marks past the page bottom draw nothing, not even a list label; an empty mark after a text paragraph closing a section stays quiet and, like any mark taking no room, draws no bullet (#3907 review) |
| 2026-10-27 | trivial | A kept-together paragraph in a continuous section is measured against the next page's body, so a tall different first-page header no longer splits one that fits on page 2 (#3907 CodeRabbit) |
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
| 2026-10-20 | trivial | A click below a floating table, outside its cells, goes to the paragraph beside or after it instead of a cell of its last row, as checked in Word (#2038) |
| 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) |
| 2026-10-19 | trivial | Justified lines (compatibility mode 15) shrink their spaces to fit one more word by at most a quarter of the spaces' width (was a fifth) and by at most 0.355 of that word's own width with the space before it. Measured in Word for Mac with synthetic justified paragraphs: words of 2.7 to 80 pt, spaces of 3.3 and 6.7 pt, only justified text, last lines included, never in modes 12 and 14. A real paper's justified abstract now breaks like Word on all 23 lines (3 before); on 180 real-world papers, page counts matching Word went from 139 to 142, none further |
+8 -2
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (Arabic: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does, matched only at the ends of the text so `1/.5` stays a fraction); previously trivial (Arabic: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Interface languages, script support and proofing per language. How translation works (adding a
language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and
@@ -219,7 +219,12 @@ Polish, `1.250,50` in German, `1,250.50` in English) and write formula results w
separator (`crates/doc/src/locale_number.rs`). Cuban and Peruvian Spanish (`es-CU`, `es-PE`) use
decimal points (`1,234.50`), matching the existing region settings and CLDR's Latin American
Spanish parent locale; `cuban_and_peruvian_numbers_use_decimal_points` covers parsing and result
formatting. Interface lists (styles, fonts) still sort by code point.
formatting. Currency signs and short currency words around the number are ignored, as Word
ignores them: a single-character sign may touch the number (`$1,250`, `1 250,00 zł`, `PLN 12`)
while the Panamanian balboa's `B/.` is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`, as
observed in Word; `B/.1,250.50` and `B/ 1,250.50` are not numbers). Because `B/.` carries a slash
and a full stop, it is matched only at the ends of the text, so `1/.5` and `1,250.50/2` are not
read as balboa amounts. Interface lists (styles, fonts) still sort by code point.
Russian (`ru`) sorting keeps ё with е (a secondary diaeresis difference), before ж. Composed and decomposed ё and й have the same keys and index headings; the stored text is unchanged. Tests cover Russian locale aliases, case, representative words and these two canonical pairs. This is a narrow Cyrillic correction, not full Unicode normalization or CLDR collation. Ordering reference: [Unicode CLDR Russian collation chart](https://unicode.org/cldr/charts/42/collation/ru.html).
@@ -270,6 +275,7 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | Table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does; `B/.1,250.50` and `B/ 1,250.50` stay text, matching Word 16.0, and the sign is matched only at the ends of the text, so `1/.5` is unchanged |
| 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review |
| 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review |
| 2026-11-03 | trivial | craft-proof pin moved to craft-libs main with #589 and #590: French suggestions offer the elided form first (`Jadore` → `J'adore` before `J adore`; English keeps plural fixes first), and Valencian (`ca-ES-valencia`) text reads the `ca-valencia` pack |
+1
View File
@@ -74,6 +74,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | trivial | DOCX text-frame anchor locks survive JSON and two save/reopen cycles, including position and size edits |
| 2026-11-03 | minor | M15: Arabic mirrored interface (#1195); 28 UI languages from craft-i18n; Hebrew UI, native-speaker review and native IME acceptance (#155–#164) remain open |
| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. |
| 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open |
+8 -1
View File
@@ -135,7 +135,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` |
| Shift+Backspace / Shift+Delete | Shift+Backspace erases like Backspace (#707). Shift+Delete cuts the selection to the clipboard and does nothing without one (Word for Windows, observed 2026-10-14). Windows delivers Shift+Delete as a Cut; macOS and Linux deliver the key, which did nothing (#1669) | `shift_delete_cuts_the_selection_and_shift_backspace_erases` |
| 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` |
| 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. A text box (Find what, a dialog's field, the ribbon's boxes) takes the same codes: its character, not the digits (#4403) | `alt_numpad_codes_map_like_word_on_windows`, `alt_numpad_code_types_one_character`, ui-egui `alt_numpad_code_types_its_character_without_keytips`, `alt_code_types_its_character_in_a_text_box` |
| AltGr (Right Alt on Polish, German, Czech… layouts) | AltGr+letter types the layout's character (AltGr+S → ś, AltGr+L → ł) and a bare AltGr tap does nothing; neither opens KeyTips. On Linux AltGr is the Right Alt key with Level 3 Shift, not Alt, so its modifiers event no longer reads as Alt let go (#2388); on Windows it is Ctrl+Alt. Right Alt on a layout without AltGr (US) is Alt and opens KeyTips like Left Alt | ui-egui `altgr_types_its_characters_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` |
@@ -356,6 +356,12 @@ the caret's paragraph, and drops it once the caret is anywhere else or the word
(`crates/engine/src/session.rs`), so a caret move without an edit lays only that paragraph out
again.
With File › Options › Proofing › Ignore web and file addresses on (the default), a web or e-mail
address is not marked, also when the space before it is a no-break space, a narrow no-break space
(U+202F), a figure space (U+2007) or an ideographic space. With the option off, the words in the
address are checked (`address_after_a_wide_space_is_an_internet_token`, in craft-libs'
`crates/proof/src/lib.rs`).
## Editing options (File › Options › Advanced)
| Keys / setting | Result | Test |
@@ -558,6 +564,7 @@ this correction; native Windows interaction and Microsoft Word remain untested.
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) |
| 2026-11-01 | minor | English spelling by dialect from SCOWL 2026: typed British, Canadian and Australian text, modern words and ordinals pinned (#40, #520, #1281, #1045; CP2) |
| 2026-10-29 | trivial | Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged |
| 2026-10-28 | trivial | Highlighting at a caret leaves the word alone and sets the colour for what is typed next (#4086). |
+3
View File
@@ -17,6 +17,8 @@ Document title opens Rename Document; the account badge opens Options › Genera
| 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. The app opens on Home every launch, as in Word: the active tab is not restored between runs, but going File and back to the editor returns to the tab that was showing, contextual tabs included. Home › Font: the font box lists Recently Used Fonts (the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, ten at most, kept between runs) above All Fonts, as in Word; the sections show while nothing is typed, including after a no-match query is cleared in a scrollable list (#2068). With Filling in forms enforced (Restrict Editing), the Developer tab's content-control buttons and Properties grey out, their tooltips saying why, as in Word. Read only and comments-only protection without an exception the author may edit grey out every edit, their tooltips saying why, and tracked-changes protection greys out Accept, Reject and Track Changes; what decides it is cached per document revision, so the ribbon doesn't walk the document on every frame | Same plus Draw | 85% | (Draw in features) |
| Keytips (Alt / F10) | Ribbon badges (#43), Backstage page badges after Alt, F (H, N, O, I, S, A, P, E, C, T; P and V open Print, #4960) and numbered Quick Access Toolbar badges in the user's button order (#620; none while the toolbar is hidden, #1945); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone, and AltGr leaves typing alone on Linux and Windows (#2388); legacy menu keys (#1697; not on macOS): 18 Word 2003 sequences Word for Windows still takes, as observed there (Alt, V, U Full Screen View; V, P/W/O/N views; V, D/L/Z; E, L/F/E/G; I, S; I, P, F; O, F/P; T, W; A, I, T), hidden, with a hint under the tab row (under the slim bar in Focus and Full Screen View); an unknown letter is ignored, Esc cancels; Alt, I, B left out (no Break dialog) | ✅ | 85% | 1–2 |
| Mini-toolbar on selection and text context menu | ✅ (#43, two rows #535; Recently Used Fonts in its font box #2068; the text context menu includes the same toolbar at a caret or selection, #2959) | ✅ | 80% | 1–2 |
| Keytips (Alt / F10) | Ribbon badges (#43) and numbered Quick Access Toolbar badges in the user's button order (#620; none while the toolbar is hidden, #1945); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone, and AltGr leaves typing alone on Linux and Windows (#2388); legacy menu keys (#1697; not on macOS): 18 Word 2003 sequences Word for Windows still takes, as observed there (Alt, V, U Full Screen View; V, P/W/O/N views; V, D/L/Z; E, L/F/E/G; I, S; I, P, F; O, F/P; T, W; A, I, T), hidden, with a hint under the tab row (under the slim bar in Focus and Full Screen View); an unknown letter is ignored, Esc cancels; Alt, I, B left out (no Break dialog) | ✅ | 85% | 1–2 |
| Mini-toolbar on selection and text context menu | ✅ (#43, two rows #535; hovering a formatting button previews it on the selected text, #4664; Recently Used Fonts in its font box #2068; the text context menu includes the same toolbar at a caret or selection, #2959) | ✅ | 80% | 1–2 |
| 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); Shape Fill › Gradient: Light and Dark Variations of the shape's colour, More Gradients… (#1500); no 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`). Its menu ends with Hide Quick Access Toolbar, which takes it out of the title bar; Show Quick Access Toolbar on the ribbon's right-click menu, or in Customize Ribbon › Quick Access Toolbar, brings it back; kept between runs; `ui.quickAccessToolbar` for scripts (#1945). Not shown below the ribbon | customizable | 92% | 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; filter-as-you-type command search in all three pickers (#1845) | ✅ | 85% | 1–2 |
@@ -117,6 +119,7 @@ Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared
| 2026-10-27 | trivial | File › Print: Return prints, as Word's Print dialog does with Print as its default button; a field or list on the page that has the keyboard keeps Return |
| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged |
| 2026-11-01 | trivial | N-up and booklet print imposition (#641): integrate current print-job link handling (#4734), preserve markup preview and sheet/copy planning; readiness estimates unchanged. |
| 2026-10-28 | trivial | Mini toolbar: after a click a button shows no preview until the pointer leaves it, and coming back previews the undo (Bold over bold text shows it unbolded); the alignment buttons of the bar and the Home and Layout ribbons follow the paragraph a selection covers, not the next one it ends at; picking from a font or size list closes it, and the recent fonts reorder the next time it opens; the font list and the colour menus mark the choice in effect, apart from the one hovered, and a family listed under Recently Used Fonts is marked there only; the page holds its scroll position while a preview is shown; while the pointer is on Bold, Italic, Underline, Strikethrough, Grow, Shrink or an alignment button, or on an entry of the font, size, highlight or font colour lists, the page shows that command run on the selection, laid out on a copy, so the document, undo history and selection do not change; moving to another button updates it, leaving the bar restores the page, a click applies the command as before (#4664) |
| 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window |
| 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window |
| 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language |
+21 -4
View File
@@ -42,9 +42,14 @@ overrides:
- mesa-vulkan-drivers
- xdg-desktop-portal
rpm:
# Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL
# and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package).
depends:
- libxkbcommon
- (vulkan-loader or libglvnd-egl)
- libc.so.6()(64bit)
- libm.so.6(GLIBC_2.35)(64bit)
- libgcc_s.so.1()(64bit)
- libxkbcommon.so.0()(64bit)
- (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit))
recommends:
- libwayland-client
- libX11
@@ -61,8 +66,20 @@ contents:
- src: ${STAGE}/usr/bin/wordcraft-cli
dst: /usr/bin/wordcraft-cli
file_info: { mode: 0755 }
- src: ${STAGE}/usr/share/
dst: /usr/share/
# Named one by one rather than as a tree of /usr/share: a tree would also own shared directories
# such as /usr/share/mime, which openSUSE's filesystem package owns with other modes, and RPM
# refuses to install a package that conflicts with it.
- src: ${STAGE}/usr/share/applications/ai.storyteller.wordcraft.desktop
dst: /usr/share/applications/ai.storyteller.wordcraft.desktop
- src: ${STAGE}/usr/share/mime/packages/ai.storyteller.wordcraft.xml
dst: /usr/share/mime/packages/ai.storyteller.wordcraft.xml
- src: ${STAGE}/usr/share/metainfo/ai.storyteller.wordcraft.metainfo.xml
dst: /usr/share/metainfo/ai.storyteller.wordcraft.metainfo.xml
- src: ${STAGE}/usr/share/icons/hicolor/
dst: /usr/share/icons/hicolor/
type: tree
- src: ${STAGE}/usr/share/doc/wordcraft/
dst: /usr/share/doc/wordcraft/
type: tree
scripts:
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more